@startsimpli/integrations 0.2.30 → 0.2.31

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.30",
3
+ "version": "0.2.31",
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",
@@ -813,9 +813,15 @@ describe('ConnectionCard — a connected account has settings', () => {
813
813
  * — "there is no id here to substitute, no slug to swap". A client that
814
814
  * takes a slug re-introduces, in the types, the exact affordance that URL
815
815
  * space was designed not to have.
816
- * * NO PATCH. `MyConnectionDetailView` serves GET and DELETE only, so the
817
- * capability picker is informational and there is no Save beside it, and no
818
- * settings form.
816
+ * * NO GENERAL PATCH. `MyConnectionDetailView` still serves GET and DELETE
817
+ * only, so a card's NAME and what it may DO stay the foundry's and the
818
+ * capability picker is informational with no Save beside it. The one write
819
+ * verb the member surface grew is `updateConnectionConfig` (bd
820
+ * startsim-sfms2) — a personal connection's config is the member's own
821
+ * privacy boundary — and it is a separate verb precisely so opening it does
822
+ * not open the capability Save. This stub does not carry it, and this
823
+ * connector declares no config fields, so no settings control renders here;
824
+ * `member-connection-scope.test.tsx` drives the surface that does.
819
825
  * * NO targets, choices, actions or tokens. Those are foundry-level and stay
820
826
  * on the control-plane router, so the Workflows control is not rendered.
821
827
  * * THE CATALOG IS THE SERVER'S ANSWER. `me/catalog/` returns only what the
@@ -0,0 +1,314 @@
1
+ /**
2
+ * RED: a member's own scope is theirs to set, on screen (bd startsim-sirp6).
3
+ *
4
+ * Quinn, 2026-08-27: "if people connect their dropbox, they will expect to be
5
+ * able to configure what they have access to. some may want root which would be
6
+ * everything, some may want to configure a subfolder. but our system should
7
+ * enable and enforce either / both."
8
+ *
9
+ * `connections.test.tsx` next door pins that personal mode has no PATCH, and
10
+ * that was right about what the router served. It is not right about a PERSONAL
11
+ * connection's config, which is not a pipeline setting: it decides which of the
12
+ * MEMBER's own files this platform reads. So the member surface grows exactly
13
+ * ONE write verb — `PATCH connections/me/<id>/config/`, beside `credential/`
14
+ * rather than on the detail route — and this file pins what that must look like
15
+ * from the browser.
16
+ *
17
+ * THREE THINGS IT IS EASY TO SHIP WITHOUT.
18
+ *
19
+ * 1. THE CONTROL HAS TO RENDER. `ConnectionCard` gates Settings on a verb
20
+ * EXISTING on the bound api. A member write endpoint that the composer never
21
+ * reaches is a change every backend test passes and no member can use, so the
22
+ * first assertion here is positive: the control is on the card.
23
+ * 2. THE CAPABILITY SAVE MUST STAY ABSENT. The narrow verb is narrow. What a
24
+ * member's account may DO is still the foundry's, and the picker stays
25
+ * informational — which is why the composer must not gate both controls on
26
+ * one verb.
27
+ * 3. THE MEMBER MUST BE TOLD THE WALK RESTARTS, and the SERVER must be what says
28
+ * it. This composer refuses to name a vendor and cannot know which fields
29
+ * bear the scope; only the connector knows. So the sentence arrives in the
30
+ * response and is rendered verbatim.
31
+ */
32
+ import * as React from 'react';
33
+ import { render, screen, waitFor, within } from '@testing-library/react';
34
+ import userEvent from '@testing-library/user-event';
35
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
36
+
37
+ import { ConnectionsPage } from '../components/pages/ConnectionsPage';
38
+ import { CredentialSchemaForm } from '../components/CredentialSchemaForm';
39
+ import type { MemberConnectionsApi } from '../components/pages/types';
40
+ import { connectionsMe } from '../api/connections';
41
+ import { setApiBase, setAuthTokenProvider } from '../api/_fetch';
42
+
43
+ /**
44
+ * A connector whose config IS somebody's privacy boundary: a folder, a switch
45
+ * that widens it, and a claim on the widest setting of all.
46
+ *
47
+ * Not Dropbox. This composer knows about FIELDS and nothing about vendors, so a
48
+ * fixture that named one would be asserting Dropbox rather than the framework.
49
+ */
50
+ const SCOPE_FIELDS = [
51
+ {
52
+ key: 'folder', label: 'Folder', type: 'string', required: false,
53
+ help: 'Leave blank for everything', placeholder: '', pattern: null, options: null,
54
+ default: '', write_only: false, mask: 'none', docs_url: '',
55
+ },
56
+ {
57
+ key: 'recursive', label: 'Include sub-folders', type: 'boolean', required: false,
58
+ help: 'Off by default', placeholder: '', pattern: null, options: null,
59
+ default: false, write_only: false, mask: 'none', docs_url: '',
60
+ },
61
+ {
62
+ key: 'whole_account_acknowledged_by', label: 'Whole-account read claimed by',
63
+ type: 'acknowledgement', required: false, help: 'Required to read everything',
64
+ placeholder: '', pattern: null, options: null, default: null,
65
+ write_only: false, mask: 'none', docs_url: '',
66
+ },
67
+ ];
68
+
69
+ const MEMBER_CATALOG = {
70
+ results: [
71
+ {
72
+ key: 'filesvendor', name: 'File Vendor', description: 'Files', logo_url: '',
73
+ docs_url: '', capabilities: ['poll'], auth_mode: 'api_key',
74
+ credential_schema: {
75
+ version: 1,
76
+ fields: [{
77
+ key: 'api_key', label: 'API key', type: 'secret', required: true, help: '',
78
+ placeholder: '', pattern: null, options: null, default: null,
79
+ write_only: true, mask: 'last4', docs_url: '',
80
+ }],
81
+ },
82
+ config_schema: { version: 1, fields: SCOPE_FIELDS },
83
+ record_types: [], supports_webhooks: false,
84
+ availability: { available: true, reason: '' },
85
+ },
86
+ ],
87
+ };
88
+
89
+ const MY_CONNECTIONS = {
90
+ results: [
91
+ {
92
+ id: 'm1', public_id: 'mpub1', name: 'My files', connector_key: 'filesvendor',
93
+ connector_name: 'File Vendor', external_account_id: '',
94
+ account_label: 'me@example.com', capabilities_enabled: ['poll'],
95
+ config: { folder: '/Contracts', recursive: false }, status: 'connected',
96
+ health: 'ok', auth_mode: 'api_key',
97
+ },
98
+ ],
99
+ permissions: { view: true, manage: true, issue_tokens: false },
100
+ };
101
+
102
+ function stubMemberApi(overrides: Partial<MemberConnectionsApi> = {}): MemberConnectionsApi {
103
+ return {
104
+ connectorCatalog: vi.fn().mockResolvedValue(MEMBER_CATALOG),
105
+ connections: vi.fn().mockResolvedValue(MY_CONNECTIONS),
106
+ connect: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
107
+ checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
108
+ rotateCredential: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
109
+ disconnect: vi.fn().mockResolvedValue(undefined),
110
+ connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
111
+ updateConnectionConfig: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
112
+ ...overrides,
113
+ } as MemberConnectionsApi;
114
+ }
115
+
116
+ async function myCard(api: MemberConnectionsApi = stubMemberApi()) {
117
+ render(<ConnectionsPage mode="personal" api={api} />);
118
+ const card = await screen.findByText('My files');
119
+ return (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
120
+ }
121
+
122
+ // --------------------------------------------------------------------------
123
+ // the control exists, and it is the narrow one
124
+ // --------------------------------------------------------------------------
125
+
126
+ describe('the member card can change its own scope', () => {
127
+ it('offers Settings on a connector that declares config fields', async () => {
128
+ const section = await myCard();
129
+ expect(
130
+ within(section).getByRole('button', { name: /Settings/i }),
131
+ ).toBeInTheDocument();
132
+ });
133
+
134
+ it('still offers no Save beside the capability picker', async () => {
135
+ // The verb is narrow ON PURPOSE. What a member's account may DO stays the
136
+ // foundry's, so gating both controls on one verb would hand back exactly the
137
+ // decision this change did not make.
138
+ const section = await myCard();
139
+ expect(within(section).queryByRole('button', { name: /^Save$/i })).toBeNull();
140
+ expect(
141
+ within(section).getByText(/Ask an admin to change what this account may do/i),
142
+ ).toBeInTheDocument();
143
+ });
144
+
145
+ it('saves the fields the member changed, and nothing else', async () => {
146
+ const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
147
+ const section = await myCard(stubMemberApi({ updateConnectionConfig }));
148
+
149
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
150
+ await userEvent.clear(within(section).getByLabelText(/^Folder$/i));
151
+ await userEvent.type(within(section).getByLabelText(/^Folder$/i), '/Invoices');
152
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
153
+
154
+ await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
155
+ expect(updateConnectionConfig.mock.calls[0]).toEqual(['m1', { folder: '/Invoices' }]);
156
+ });
157
+
158
+ it('sends a real boolean when a switch is turned ON', async () => {
159
+ // `bool("false")` is `true`. A switch that travels as a string is the whole
160
+ // reason `type` exists on a field spec.
161
+ const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
162
+ const section = await myCard(stubMemberApi({ updateConnectionConfig }));
163
+
164
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
165
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
166
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
167
+
168
+ await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
169
+ expect(updateConnectionConfig.mock.calls[0][1]).toEqual({ recursive: true });
170
+ });
171
+
172
+ it('sends a real boolean when a switch is turned OFF', async () => {
173
+ // Against a MERGING api, a client that drops falsy values makes the off
174
+ // position literally unreachable — for a scope switch that is the narrow
175
+ // setting nobody can get back to.
176
+ const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
177
+ const api = stubMemberApi({
178
+ updateConnectionConfig,
179
+ connections: vi.fn().mockResolvedValue({
180
+ ...MY_CONNECTIONS,
181
+ results: [{
182
+ ...MY_CONNECTIONS.results[0],
183
+ config: { folder: '/Contracts', recursive: true },
184
+ }],
185
+ }),
186
+ });
187
+ const section = await myCard(api);
188
+
189
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
190
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
191
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
192
+
193
+ await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
194
+ expect(updateConnectionConfig.mock.calls[0][1]).toEqual({ recursive: false });
195
+ });
196
+ });
197
+
198
+ // --------------------------------------------------------------------------
199
+ // what the server says, the member reads
200
+ // --------------------------------------------------------------------------
201
+
202
+ describe('the member is told what the change costs', () => {
203
+ it('renders the notices the SERVER returned, verbatim', async () => {
204
+ const sentence =
205
+ 'Changing what this connection covers restarts the walk: the next run '
206
+ + 're-reads this account from the beginning.';
207
+ const updateConnectionConfig = vi.fn().mockResolvedValue({
208
+ ...MY_CONNECTIONS.results[0], notices: [sentence],
209
+ });
210
+ const section = await myCard(stubMemberApi({ updateConnectionConfig }));
211
+
212
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
213
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
214
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
215
+
216
+ await waitFor(() => expect(screen.getByText(sentence)).toBeInTheDocument());
217
+ });
218
+
219
+ it('says nothing extra when the server said nothing', async () => {
220
+ const section = await myCard();
221
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
222
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
223
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
224
+ await waitFor(() =>
225
+ expect(within(section).getByText(/Settings saved/i)).toBeInTheDocument());
226
+ expect(within(section).queryByText(/from the beginning/i)).toBeNull();
227
+ });
228
+ });
229
+
230
+ // --------------------------------------------------------------------------
231
+ // an acknowledgement is a control, and it shows who claimed it
232
+ // --------------------------------------------------------------------------
233
+
234
+ describe('CredentialSchemaForm renders an acknowledgement as consent', () => {
235
+ function renderAck(stored: unknown, onChange = vi.fn()) {
236
+ render(
237
+ <CredentialSchemaForm
238
+ fields={SCOPE_FIELDS}
239
+ values={{ whole_account_acknowledged_by: stored }}
240
+ onChange={onChange}
241
+ />,
242
+ );
243
+ return onChange;
244
+ }
245
+
246
+ it('is a checkbox, not a box to type somebody’s name into', () => {
247
+ renderAck('');
248
+ const control = screen.getByLabelText(/Whole-account read claimed by/i);
249
+ expect(control).toHaveAttribute('type', 'checkbox');
250
+ expect((control as HTMLInputElement).checked).toBe(false);
251
+ });
252
+
253
+ it('emits a boolean, because the SERVER writes who and when', () => {
254
+ const onChange = renderAck('');
255
+ return userEvent.click(screen.getByLabelText(/Whole-account read claimed by/i)).then(() => {
256
+ expect(onChange).toHaveBeenCalledWith('whole_account_acknowledged_by', true);
257
+ });
258
+ });
259
+
260
+ it('shows the recorded attribution once somebody has claimed it', () => {
261
+ // A control that records nothing would be worse than the text box it
262
+ // replaced. The record has to be READABLE, or nobody can check it.
263
+ renderAck('dana.reed@example.com on 2026-08-27T09:00:00Z');
264
+ const control = screen.getByLabelText(/Whole-account read claimed by/i) as HTMLInputElement;
265
+ expect(control.checked).toBe(true);
266
+ expect(screen.getByText(/dana\.reed@example\.com/)).toBeInTheDocument();
267
+ expect(screen.getByText(/2026-08-27T09:00:00Z/)).toBeInTheDocument();
268
+ });
269
+ });
270
+
271
+ // --------------------------------------------------------------------------
272
+ // and the client lands on the narrow route
273
+ // --------------------------------------------------------------------------
274
+
275
+ describe('connectionsMe.updateConnectionConfig', () => {
276
+ interface Call { url: string; method: string; body: unknown }
277
+ let calls: Call[] = [];
278
+
279
+ beforeEach(() => {
280
+ calls = [];
281
+ setApiBase('/api/v1');
282
+ setAuthTokenProvider(null);
283
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit = {}) => {
284
+ calls.push({
285
+ url: String(url),
286
+ method: init.method ?? 'GET',
287
+ body: init.body ? JSON.parse(String(init.body)) : undefined,
288
+ });
289
+ return new Response(JSON.stringify({ id: 'm1' }), {
290
+ status: 200, headers: { 'Content-Type': 'application/json' },
291
+ });
292
+ }));
293
+ });
294
+
295
+ afterEach(() => { vi.unstubAllGlobals(); });
296
+
297
+ it('PATCHes the CONFIG sub-route, not the detail route', async () => {
298
+ // Beside `credential/`, and for the same reason it is: a scope change has to
299
+ // pass the connector's own refusal before it replaces a working setting.
300
+ // The detail route keeps having no PATCH at all.
301
+ await connectionsMe.updateConnectionConfig!('abc', { folder: '/Invoices' });
302
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/config/');
303
+ expect(calls[0].method).toBe('PATCH');
304
+ expect(calls[0].body).toEqual({ config: { folder: '/Invoices' } });
305
+ });
306
+
307
+ it('names no slug, no foundry and no user', async () => {
308
+ await connectionsMe.updateConnectionConfig!('abc', { folder: '/Invoices' });
309
+ expect(calls[0].url.startsWith('/api/v1/connections/me/')).toBe(true);
310
+ expect(calls[0].url).not.toMatch(/[?&]/);
311
+ expect(JSON.stringify(calls[0].body)).not.toMatch(
312
+ /slug|foundry|tenant|company|owner|user_id/i);
313
+ });
314
+ });
@@ -18,11 +18,18 @@
18
18
  * token in flight decides which foundry answers, and nothing here can override
19
19
  * that.
20
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.
21
+ * WHAT IS ABSENT IS ALSO THE CONTRACT. There is still no `updateConnection`:
22
+ * `MyConnectionDetailView` serves GET and DELETE only, so a card's name and what
23
+ * it may DO stay with the foundry. No choices, targets, actions or
24
+ * `ConnectionToken`s either — those are foundry-level and owner-exclusive on the
25
+ * control-plane router. Adding one here would be a client for a route that does
26
+ * not exist.
27
+ *
28
+ * `updateConnectionConfig` is the ONE write verb that was added (bd
29
+ * startsim-sfms2), and it points at `me/<id>/config/` rather than at the detail
30
+ * route for the same reason `rotateCredential` points at `credential/`: a change
31
+ * that has to pass a gate gets its own route instead of a field on a general
32
+ * PATCH.
26
33
  *
27
34
  * WHERE IT POINTS. Through `request()`, so `setApiBase` and
28
35
  * `setAuthTokenProvider` apply: a tenant app points the base at its own
@@ -155,6 +162,37 @@ export const connectionsMe: MemberConnectionsApi = {
155
162
  });
156
163
  },
157
164
 
165
+ /**
166
+ * CHANGE THE SETTINGS OF MY OWN CARD (bd startsim-sfms2).
167
+ *
168
+ * The folder a Dropbox connection reads, and whether it recurses. On a
169
+ * personal connection that is not a pipeline setting — it is which of the
170
+ * member's own files this platform is allowed to see — so it is theirs to set,
171
+ * and the alternative was disconnect + re-consent, which throws a working
172
+ * token away to change a string.
173
+ *
174
+ * A SUB-ROUTE, not a PATCH on the detail view. `credential/` is the precedent
175
+ * one door down: rotation is not on PATCH either, because a new key has to
176
+ * pass `check()` before it replaces a working one. A scope change has the same
177
+ * shape — it must pass the connector's own refusal, and it restarts the walk.
178
+ *
179
+ * PARTIAL and MERGING, exactly like `rotateCredential`: send the fields that
180
+ * changed and the stored values fill in the rest. `null` unsets one. The server
181
+ * refuses any key the connector does not declare, and refuses any TOP-LEVEL key
182
+ * that is not `config` — a body naming `capabilities_enabled` is a 400 and not
183
+ * a silent no-op.
184
+ *
185
+ * The response may carry `notices`: sentences the server wrote about what the
186
+ * change costs. Rendered verbatim, never composed in the browser.
187
+ */
188
+ updateConnectionConfig(
189
+ id: string, config: Record<string, unknown>,
190
+ ): Promise<Connection> {
191
+ return request<Connection>(`${BASE}/${id}/config/`, {
192
+ method: 'PATCH', body: { config },
193
+ });
194
+ },
195
+
158
196
  /**
159
197
  * DISCONNECT, not delete. One transaction stops the scheduled fetches, revokes
160
198
  * every token issued for the account and deletes the stored credential; the
@@ -51,6 +51,9 @@ export interface CredentialSchemaFormProps {
51
51
  /** Field types this build renders natively. Anything else becomes text. */
52
52
  const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
53
53
 
54
+ /** The server's spelling for a consent record. See `isAcknowledgement`. */
55
+ const ACKNOWLEDGEMENT = 'acknowledgement';
56
+
54
57
  /**
55
58
  * A boolean is a CHECKBOX, and this is not a cosmetic preference.
56
59
  *
@@ -61,12 +64,37 @@ const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'texta
61
64
  * conversations are stored, and in a text box the way you turn it off is to type
62
65
  * the word "false" — which `bool()` reads as ON.
63
66
  */
64
- const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool']);
67
+ const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool', ACKNOWLEDGEMENT]);
65
68
 
66
69
  export function isBoolean(spec: ConnectorFieldSpec): boolean {
67
70
  return !isSecret(spec) && BOOLEAN_TYPES.has(String(spec.type || '').toLowerCase());
68
71
  }
69
72
 
73
+ /**
74
+ * An ACKNOWLEDGEMENT is a consent record, not a setting (bd startsim-sfms2).
75
+ *
76
+ * It marks the fields that say a named human claimed a disclosure that is legal,
77
+ * large, and much bigger than most people mean — Dropbox's
78
+ * `whole_account_acknowledged_by`, which gates reading metadata for every file
79
+ * in somebody's account.
80
+ *
81
+ * Two things follow, and both are why it is not merely `boolean`. What the
82
+ * person DOES is tick a claim, so this emits `true`/`false` and the SERVER
83
+ * writes `<who> on <when>` from the authenticated caller — there is no box to
84
+ * type somebody else's name into, and no request body that can attribute a claim
85
+ * to anyone but its sender. And what the field HOLDS is that attribution, so it
86
+ * is rendered beside the control: a claim nobody can read is a record in name
87
+ * only, which is worse than the text box it replaced.
88
+ */
89
+ export function isAcknowledgement(spec: ConnectorFieldSpec): boolean {
90
+ return !isSecret(spec) && String(spec.type || '').toLowerCase() === ACKNOWLEDGEMENT;
91
+ }
92
+
93
+ /** What the server wrote into an acknowledgement, or '' when nobody has claimed it. */
94
+ export function acknowledgedBy(value: unknown): string {
95
+ return typeof value === 'string' && asBoolean(value) ? value.trim() : '';
96
+ }
97
+
70
98
  /** The spellings of "off" that can arrive from a stored config or a form. */
71
99
  export function asBoolean(value: unknown): boolean {
72
100
  if (typeof value === 'string') {
@@ -185,6 +213,15 @@ export function CredentialSchemaForm({
185
213
  />
186
214
  )}
187
215
 
216
+ {/* THE RECORD, beside the control that made it. `who` and `when`
217
+ are written by the server; nothing here can author or edit them.
218
+ Rendered because a consent record nobody can read is not one. */}
219
+ {isAcknowledgement(spec) && acknowledgedBy(stored) ? (
220
+ <p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>
221
+ Claimed by {acknowledgedBy(stored)}
222
+ </p>
223
+ ) : null}
224
+
188
225
  {spec.help ? (
189
226
  <p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>{spec.help}</p>
190
227
  ) : null}
@@ -111,6 +111,20 @@ interface BoundApi {
111
111
  updateConnection?(id: string, input: {
112
112
  name?: string; capabilities_enabled?: string[]; config?: Record<string, unknown>;
113
113
  }): Promise<Connection>;
114
+ /**
115
+ * SETTINGS ALONE, and separate from `updateConnection` on purpose
116
+ * (bd startsim-sfms2).
117
+ *
118
+ * Both surfaces have it; only the control plane has `updateConnection`. A
119
+ * personal connection's config is the MEMBER's privacy boundary — which of
120
+ * their own files this platform reads — while its name and what it may DO are
121
+ * the foundry's. One verb carrying all three would have made the Settings
122
+ * control and the capability Save appear together, so opening the first would
123
+ * have opened the second by accident.
124
+ */
125
+ updateConnectionConfig?(
126
+ id: string, config: Record<string, unknown>,
127
+ ): Promise<Connection>;
114
128
  connectionChoices?(id: string, params?: { kind?: string; cursor?: string }):
115
129
  Promise<ConnectionChoicePage>;
116
130
  wireTarget?(id: string, input: {
@@ -177,6 +191,12 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
177
191
  rotateCredential: (id, credentials) => api.rotateCredential(slug, id, credentials),
178
192
  disconnect: (id) => api.disconnect(slug, id),
179
193
  updateConnection: (id, input) => api.updateConnection(slug, id, input),
194
+ // The control plane reaches settings through the same PATCH it reaches
195
+ // everything else with; the narrow verb is a property of the MEMBER router,
196
+ // not of the form. Both modes therefore satisfy the same bound signature and
197
+ // the settings form never learns which surface it is on.
198
+ updateConnectionConfig: (id, config) =>
199
+ api.updateConnection(slug, id, { config }),
180
200
  connectionChoices: (id, params) => api.connectionChoices(slug, id, params),
181
201
  wireTarget: (id, input) => api.wireTarget(slug, id, input),
182
202
  confirmTarget: (id, destinationId) => api.confirmTarget(slug, id, destinationId),
@@ -971,7 +991,8 @@ function ConnectionSettingsForm({
971
991
  connection: Connection;
972
992
  fields: ConnectorFieldSpec[];
973
993
  classNames: typeof DEFAULTS;
974
- onDone: () => void;
994
+ /** Handed whatever the SERVER said about the change, so the card can say it. */
995
+ onDone: (notices: string[]) => void;
975
996
  }) {
976
997
  const stored = React.useMemo(
977
998
  () => (connection.config ?? {}) as Record<string, unknown>,
@@ -1001,8 +1022,15 @@ function ConnectionSettingsForm({
1001
1022
  ? Number(value)
1002
1023
  : value;
1003
1024
  }
1004
- await api.updateConnection!(connection.id, { config });
1005
- onDone();
1025
+ const saved = await api.updateConnectionConfig!(connection.id, config);
1026
+ // WHAT THE SERVER WANTS THEM TO KNOW, handed up rather than rendered here.
1027
+ // The live case is a scope change restarting the walk, and this file
1028
+ // cannot work that out: it names no vendor and therefore cannot know which
1029
+ // fields bear the scope. A heuristic on field names would be both a vendor
1030
+ // branch and untestable at the level that matters. Handed to the CARD
1031
+ // because saving closes this panel, and a sentence that unmounts with the
1032
+ // form is one nobody reads.
1033
+ onDone(saved?.notices ?? []);
1006
1034
  } catch (err) {
1007
1035
  setError((err as { message?: string })?.message || 'Those settings were not saved.');
1008
1036
  } finally {
@@ -1055,6 +1083,8 @@ function ConnectionCard({
1055
1083
  const [busy, setBusy] = React.useState('');
1056
1084
  const [error, setError] = React.useState('');
1057
1085
  const [notice, setNotice] = React.useState('');
1086
+ /** Sentences the SERVER wrote about the last settings save. Never composed here. */
1087
+ const [notices, setNotices] = React.useState<string[]>([]);
1058
1088
  const [replacing, setReplacing] = React.useState(false);
1059
1089
  const [settingsOpen, setSettingsOpen] = React.useState(false);
1060
1090
  const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
@@ -1164,8 +1194,16 @@ function ConnectionCard({
1164
1194
  all, which for Granola means the switch deciding whether verbatim
1165
1195
  recordings of people's conversations are stored was reachable
1166
1196
  only by editing the database. `ReplaceKeyForm` already pointed at
1167
- "the connection's own settings" as if this existed. */}
1168
- {configFields.length && api.updateConnection ? (
1197
+ "the connection's own settings" as if this existed.
1198
+
1199
+ GATED ON THE SETTINGS VERB, not on the general one (bd
1200
+ startsim-sfms2). The member surface has `updateConnectionConfig`
1201
+ and deliberately not `updateConnection`, because a personal
1202
+ connection's config is the MEMBER's privacy boundary while its
1203
+ name and what it may do stay the foundry's. One gate for both
1204
+ would have made opening this control open the capability Save
1205
+ beside it, which is a decision this change did not make. */}
1206
+ {configFields.length && api.updateConnectionConfig ? (
1169
1207
  <button
1170
1208
  type="button"
1171
1209
  className={cn.button}
@@ -1202,7 +1240,12 @@ function ConnectionCard({
1202
1240
  connection={connection}
1203
1241
  fields={configFields}
1204
1242
  classNames={cn}
1205
- onDone={() => { setSettingsOpen(false); setNotice('Settings saved.'); onChanged(); }}
1243
+ onDone={(said) => {
1244
+ setSettingsOpen(false);
1245
+ setNotice('Settings saved.');
1246
+ setNotices(said);
1247
+ onChanged();
1248
+ }}
1206
1249
  />
1207
1250
  ) : null}
1208
1251
 
@@ -1308,6 +1351,11 @@ function ConnectionCard({
1308
1351
  </CapabilityPicker>
1309
1352
 
1310
1353
  {notice ? <p className={cn.muted}>{notice}</p> : null}
1354
+ {/* WHAT THE CHANGE COSTS, in the server's words. Rendered verbatim: this
1355
+ file names no vendor and cannot know which settings restart a walk. */}
1356
+ {notices.map((said) => (
1357
+ <p key={said} className={cn.muted}>{said}</p>
1358
+ ))}
1311
1359
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
1312
1360
 
1313
1361
  {choices ? (
@@ -29,7 +29,12 @@ export interface ConnectorFieldSpec {
29
29
  help?: string;
30
30
  placeholder?: string;
31
31
  options?: Array<{ value: string; label: string }> | null;
32
- default?: string | null;
32
+ /**
33
+ * What happens if nobody touches this field. `unknown`, not `string`: a config
34
+ * schema declares booleans and numbers, and a `default: false` typed as a
35
+ * string is the same class of lie as a checkbox rendered as a text box.
36
+ */
37
+ default?: unknown;
33
38
  pattern?: string | null;
34
39
  docs_url?: string;
35
40
  }
@@ -148,6 +153,16 @@ export interface Connection {
148
153
  auth_mode: string;
149
154
  check_error?: string;
150
155
  last_checked_at?: string | null;
156
+ /**
157
+ * WHAT THE SERVER WANTS THE PERSON WHO JUST SAVED TO KNOW. Present on the
158
+ * response to a config write and NOWHERE else — never stored, never on a GET.
159
+ *
160
+ * The live case is a scope change: it restarts the walk, because the connector
161
+ * discards a sync cursor whose scope no longer matches. This composer refuses
162
+ * to name a vendor and cannot know which fields bear the scope; only the
163
+ * connector does, so only the server can say it. Rendered verbatim.
164
+ */
165
+ notices?: string[];
151
166
  }
152
167
 
153
168
  /** What to RENDER, never what to allow. The server refuses on its own. */
@@ -389,11 +404,37 @@ export interface MemberConnectionsApi {
389
404
  config?: Record<string, unknown>;
390
405
  /**
391
406
  * Chosen once, at connect time, and not editable afterwards on this
392
- * surface — there is no PATCH. The picker on an existing card is therefore
393
- * informational, and saying so is the honest rendering of both halves.
407
+ * surface — the detail route has no PATCH and `config/` accepts nothing
408
+ * else. The picker on an existing card is therefore informational, and
409
+ * saying so is the honest rendering of both halves.
394
410
  */
395
411
  capabilities_enabled?: string[];
396
412
  }): Promise<Connection>;
413
+ /**
414
+ * CHANGE THE SETTINGS OF A CONNECTION I OWN (bd startsim-sfms2).
415
+ *
416
+ * The one write verb this surface grew, and it is narrow on purpose. A
417
+ * personal connection's config is not a pipeline setting: it names the folder
418
+ * of somebody's own account this platform may read. Routing that through an
419
+ * admin makes another person the author of that member's privacy boundary,
420
+ * and the only re-scope available without it was disconnect + re-consent —
421
+ * throwing away a working token to change a string.
422
+ *
423
+ * NOT `updateConnection`, and the difference is load-bearing: the foundry
424
+ * verb of that name also carries `name` and `capabilities_enabled`, which stay
425
+ * the foundry's. A composer that gated both controls on one verb would hand
426
+ * back a decision this change did not make.
427
+ *
428
+ * PARTIAL and MERGING: send the fields that changed. `null` unsets one. The
429
+ * server refuses any key the connector does not declare.
430
+ *
431
+ * Optional so an app pinned to an older `@startsimpli/api` keeps
432
+ * type-checking; the card renders the Settings control only when the verb is
433
+ * present, so a stale client degrades to no control rather than a dead button.
434
+ */
435
+ updateConnectionConfig?(
436
+ id: string, config: Record<string, unknown>,
437
+ ): Promise<Connection>;
397
438
  checkConnection(id: string): Promise<{
398
439
  ok: boolean; account_label: string; detail: string;
399
440
  }>;