@startsimpli/integrations 0.2.29 → 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 +1 -1
- package/src/__tests__/barrel-exports.test.ts +78 -0
- package/src/__tests__/connections.test.tsx +9 -3
- package/src/__tests__/member-connection-scope.test.tsx +314 -0
- package/src/api/connections.ts +43 -5
- package/src/components/CredentialSchemaForm.tsx +38 -1
- package/src/components/index.ts +17 -0
- package/src/components/pages/ConnectionsPage.tsx +54 -6
- package/src/components/pages/types.ts +44 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY PAGE COMPOSER IS REACHABLE FROM THE PACKAGE ROOT.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `PlanningPage` shipped in the tarball — the FILE was there,
|
|
5
|
+
* at `src/components/pages/PlanningPage.tsx` — while the one line exporting it
|
|
6
|
+
* from `src/components/index.ts` had been dropped by an unrelated PR's merge
|
|
7
|
+
* resolution. Nothing caught it: type-check passes (the file compiles), the unit
|
|
8
|
+
* tests pass (they import the module directly), and `npm pack` includes it. The
|
|
9
|
+
* failure surfaced two publishes later, in a CUSTOMER'S docker build:
|
|
10
|
+
*
|
|
11
|
+
* The export PlanningPage was not found in module
|
|
12
|
+
* .../@startsimpli/integrations/src/index.ts
|
|
13
|
+
* Did you mean to import ConnectionsPage?
|
|
14
|
+
*
|
|
15
|
+
* That is the worst possible place to find it — a tenant's deploy, against a
|
|
16
|
+
* published artifact, with the composer's own tests green the whole time.
|
|
17
|
+
*
|
|
18
|
+
* So the barrel is asserted against the DIRECTORY rather than against a list
|
|
19
|
+
* somebody has to remember to update. A new composer is exported or this fails;
|
|
20
|
+
* a merge that drops an export line fails here instead of in a deploy.
|
|
21
|
+
*/
|
|
22
|
+
import { readdirSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { describe, expect, it } from 'vitest';
|
|
25
|
+
|
|
26
|
+
import * as pkg from '../index';
|
|
27
|
+
|
|
28
|
+
const PAGES_DIR = join(__dirname, '..', 'components', 'pages');
|
|
29
|
+
|
|
30
|
+
/** Files that are not composers and are deliberately not re-exported by name. */
|
|
31
|
+
const NOT_A_COMPOSER = new Set([
|
|
32
|
+
// Shared prop/api types — re-exported explicitly and selectively by the barrel
|
|
33
|
+
// so a fork gets the ones it needs without the internal slot unions.
|
|
34
|
+
'types.ts',
|
|
35
|
+
// The shared pieces two composers mount; exported by NAME, asserted below.
|
|
36
|
+
'work-items.tsx',
|
|
37
|
+
// A SUB-COMPONENT, not a composer: ConnectionsPage is its only mount, and the
|
|
38
|
+
// roster is the one list on that screen that shows OTHER people's accounts —
|
|
39
|
+
// it is meaningless outside the page that gates it. Listed here rather than
|
|
40
|
+
// exported, so this stays a real assertion about what a fork can reach and not
|
|
41
|
+
// a rubber stamp.
|
|
42
|
+
'MemberConnectionRoster.tsx',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function composerNames(): string[] {
|
|
46
|
+
return readdirSync(PAGES_DIR)
|
|
47
|
+
.filter((f) => f.endsWith('.tsx') && !NOT_A_COMPOSER.has(f))
|
|
48
|
+
.map((f) => f.replace(/\.tsx$/, ''));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('package barrel', () => {
|
|
52
|
+
it('re-exports every page composer in components/pages', () => {
|
|
53
|
+
const missing = composerNames().filter((name) => !(name in pkg));
|
|
54
|
+
expect(missing, `not exported from @startsimpli/integrations: ${missing.join(', ')}`)
|
|
55
|
+
.toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('exports the pieces the tracker and the planner share', () => {
|
|
59
|
+
// These are what stop the two screens drifting. If the barrel loses them, a
|
|
60
|
+
// fork silently reimplements the row and the divergence starts.
|
|
61
|
+
for (const name of ['WorkItemRow', 'UnfilteredNotice', 'isOverdue', 'compareForScreen']) {
|
|
62
|
+
expect(pkg, `missing shared export ${name}`).toHaveProperty(name);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('exports the planning client alongside its screen', () => {
|
|
67
|
+
// A screen with no client is a mount nobody can wire; they ship together or
|
|
68
|
+
// the fork gets a confusing half-error at build time.
|
|
69
|
+
expect(pkg).toHaveProperty('PlanningPage');
|
|
70
|
+
expect(pkg).toHaveProperty('createPlanningApi');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('still exports the composers that were already shipping', () => {
|
|
74
|
+
for (const name of ['ConnectionsPage', 'LibraryPage', 'AskPage', 'TrackerPage']) {
|
|
75
|
+
expect(pkg, `regressed export ${name}`).toHaveProperty(name);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -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
|
|
817
|
-
*
|
|
818
|
-
*
|
|
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
|
+
});
|
package/src/api/connections.ts
CHANGED
|
@@ -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
|
|
22
|
-
* `MyConnectionDetailView` serves GET and DELETE only
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* for a route that does
|
|
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}
|
package/src/components/index.ts
CHANGED
|
@@ -36,3 +36,20 @@ export type {
|
|
|
36
36
|
export * from './pages/LibraryPage';
|
|
37
37
|
export * from './pages/AskPage';
|
|
38
38
|
export * from './pages/TrackerPage';
|
|
39
|
+
|
|
40
|
+
// The planning toolset (bd startsim-8l6ef): focuses, and the items filed under
|
|
41
|
+
// them. `work-items` carries the pieces the tracker and the planner SHARE — the
|
|
42
|
+
// row, the honesty banner, the overdue/changed predicates — so two views over
|
|
43
|
+
// one list cannot drift into disagreeing about what they mean.
|
|
44
|
+
export * from './pages/PlanningPage';
|
|
45
|
+
export {
|
|
46
|
+
PRIORITY_LABELS,
|
|
47
|
+
UnfilteredNotice,
|
|
48
|
+
WORK_ITEM_DEFAULTS,
|
|
49
|
+
WorkItemRow,
|
|
50
|
+
changedSince,
|
|
51
|
+
compareForScreen,
|
|
52
|
+
isOverdue,
|
|
53
|
+
priorityLabel,
|
|
54
|
+
} from './pages/work-items';
|
|
55
|
+
export type { WorkItemSlot } from './pages/work-items';
|
|
@@ -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
|
-
|
|
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.
|
|
1005
|
-
|
|
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
|
-
|
|
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={() => {
|
|
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
|
-
|
|
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 —
|
|
393
|
-
*
|
|
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
|
}>;
|