@startsimpli/integrations 0.2.23 → 0.2.25
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 +2 -2
- package/src/__tests__/connections.test.tsx +131 -0
- package/src/__tests__/corpus-freshness.test.tsx +243 -0
- package/src/api/connections.ts +46 -0
- package/src/components/pages/ConnectionsPage.tsx +139 -15
- package/src/components/pages/types.ts +59 -0
- package/src/corpus/client.ts +51 -6
- package/src/corpus/types.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"react-dom": "^19.0.0",
|
|
49
49
|
"typescript": "^6.0.0",
|
|
50
50
|
"vitest": "^3.0.0",
|
|
51
|
-
"@startsimpli/llm": "0.4.
|
|
51
|
+
"@startsimpli/llm": "0.4.18"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"type-check": "tsc --noEmit",
|
|
@@ -1107,3 +1107,134 @@ describe('MemberConnectionRoster — Disconnect is not offered twice', () => {
|
|
|
1107
1107
|
expect(api.disconnectMemberConnection).toHaveBeenCalledWith('acme', 'm-live'));
|
|
1108
1108
|
});
|
|
1109
1109
|
});
|
|
1110
|
+
|
|
1111
|
+
// --------------------------------------------------------------------------
|
|
1112
|
+
// auth_mode: the OAuth branch (bd startsim-768w.22.25.14 / .23)
|
|
1113
|
+
// --------------------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* `schema.auth_mode_for`'s own docstring calls `auth_mode` "the ONLY thing the
|
|
1117
|
+
* connect card branches on", and until this suite existed the card never
|
|
1118
|
+
* branched: `fields = [...credential_schema, ...config_schema]` and one
|
|
1119
|
+
* unconditional `CredentialSchemaForm`. On the admin catalog that rendered
|
|
1120
|
+
* Dropbox's `access_token` / `refresh_token` / `expires_at` as text inputs —
|
|
1121
|
+
* the product asking a human to go and mint a refresh token by hand.
|
|
1122
|
+
*/
|
|
1123
|
+
const OAUTH_ENTRY = {
|
|
1124
|
+
key: 'dropbox',
|
|
1125
|
+
name: 'Dropbox',
|
|
1126
|
+
description: 'Poll file metadata',
|
|
1127
|
+
logo_url: '',
|
|
1128
|
+
docs_url: '',
|
|
1129
|
+
capabilities: ['poll'],
|
|
1130
|
+
auth_mode: 'oauth',
|
|
1131
|
+
credential_schema: {
|
|
1132
|
+
version: 1,
|
|
1133
|
+
fields: [
|
|
1134
|
+
{ key: 'access_token', label: 'Access token', type: 'secret', required: true },
|
|
1135
|
+
{ key: 'refresh_token', label: 'Refresh token', type: 'secret', required: false },
|
|
1136
|
+
],
|
|
1137
|
+
},
|
|
1138
|
+
config_schema: {
|
|
1139
|
+
version: 1,
|
|
1140
|
+
fields: [{ key: 'path', label: 'Folder', type: 'string', required: false }],
|
|
1141
|
+
},
|
|
1142
|
+
record_types: [],
|
|
1143
|
+
supports_webhooks: true,
|
|
1144
|
+
availability: { available: true, reason: '' },
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const START = {
|
|
1148
|
+
authorization_url:
|
|
1149
|
+
'https://www.dropbox.com/oauth2/authorize?client_id=abc&response_type=code'
|
|
1150
|
+
+ '&token_access_type=offline&redirect_uri=https%3A%2F%2Fapi.example%2Fcb%2F&state=signed',
|
|
1151
|
+
state: 'signed',
|
|
1152
|
+
redirect_uri: 'https://api.example/cb/',
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
function oauthApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
1156
|
+
return stubApi({
|
|
1157
|
+
connectorCatalog: vi.fn().mockResolvedValue({ results: [OAUTH_ENTRY] }),
|
|
1158
|
+
startOAuth: vi.fn().mockResolvedValue(START),
|
|
1159
|
+
...overrides,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function openOAuthCard(api: ConnectionsApi) {
|
|
1164
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
1165
|
+
await screen.findByText('Dropbox');
|
|
1166
|
+
await userEvent.click(screen.getByRole('button', { name: /^connect$/i }));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
describe('an OAuth connector is never a form asking for token material', () => {
|
|
1170
|
+
it('renders NO credential inputs for an oauth connector', async () => {
|
|
1171
|
+
await openOAuthCard(oauthApi());
|
|
1172
|
+
expect(screen.queryByLabelText(/access token/i)).not.toBeInTheDocument();
|
|
1173
|
+
expect(screen.queryByLabelText(/refresh token/i)).not.toBeInTheDocument();
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it('still asks for config, which is not a secret', async () => {
|
|
1177
|
+
await openOAuthCard(oauthApi());
|
|
1178
|
+
expect(screen.getByLabelText(/folder/i)).toBeInTheDocument();
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it('offers a hand-off to the vendor instead of Test and save', async () => {
|
|
1182
|
+
await openOAuthCard(oauthApi());
|
|
1183
|
+
expect(screen.getByRole('button', { name: /continue to dropbox/i })).toBeInTheDocument();
|
|
1184
|
+
expect(screen.queryByRole('button', { name: /test and save/i })).not.toBeInTheDocument();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it('an api_key connector is untouched by the branch', async () => {
|
|
1188
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
1189
|
+
await screen.findByText('n8n');
|
|
1190
|
+
await userEvent.click(screen.getAllByRole('button', { name: /^connect$/i })[0]);
|
|
1191
|
+
expect(screen.getByRole('button', { name: /test and save/i })).toBeInTheDocument();
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
describe('the hand-off sends the browser to the consent URL', () => {
|
|
1196
|
+
it('starts the round trip with the connector key and the gathered config', async () => {
|
|
1197
|
+
const api = oauthApi();
|
|
1198
|
+
await openOAuthCard(api);
|
|
1199
|
+
await userEvent.type(screen.getByLabelText(/folder/i), '/Reports');
|
|
1200
|
+
await userEvent.click(screen.getByRole('button', { name: /continue to dropbox/i }));
|
|
1201
|
+
await waitFor(() => expect(api.startOAuth).toHaveBeenCalled());
|
|
1202
|
+
const [slug, input] = (api.startOAuth as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
1203
|
+
expect(slug).toBe('acme');
|
|
1204
|
+
expect(input.connector_key).toBe('dropbox');
|
|
1205
|
+
expect(input.config).toEqual({ path: '/Reports' });
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('opens the URL VERBATIM — the client adds no parameters of its own', async () => {
|
|
1209
|
+
const assign = vi.fn();
|
|
1210
|
+
const original = window.location;
|
|
1211
|
+
Object.defineProperty(window, 'location', {
|
|
1212
|
+
configurable: true, value: { ...original, href: 'https://app.example/c', assign },
|
|
1213
|
+
});
|
|
1214
|
+
try {
|
|
1215
|
+
const api = oauthApi();
|
|
1216
|
+
await openOAuthCard(api);
|
|
1217
|
+
await userEvent.click(screen.getByRole('button', { name: /continue to dropbox/i }));
|
|
1218
|
+
await waitFor(() => expect(assign).toHaveBeenCalledWith(START.authorization_url));
|
|
1219
|
+
} finally {
|
|
1220
|
+
Object.defineProperty(window, 'location', { configurable: true, value: original });
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('surfaces a refusal instead of navigating', async () => {
|
|
1225
|
+
const api = oauthApi({
|
|
1226
|
+
startOAuth: vi.fn().mockRejectedValue(new Error('no app registration')),
|
|
1227
|
+
});
|
|
1228
|
+
await openOAuthCard(api);
|
|
1229
|
+
await userEvent.click(screen.getByRole('button', { name: /continue to dropbox/i }));
|
|
1230
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(/no app registration/i);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it('a client without the verb says so rather than offering a dead button', async () => {
|
|
1234
|
+
const api = oauthApi();
|
|
1235
|
+
delete (api as { startOAuth?: unknown }).startOAuth;
|
|
1236
|
+
await openOAuthCard(api);
|
|
1237
|
+
expect(screen.getByText(/cannot start a dropbox sign-in yet/i)).toBeInTheDocument();
|
|
1238
|
+
expect(screen.getByRole('button', { name: /continue to dropbox/i })).toBeDisabled();
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THREE FACTS, NOT ONE (bd startsim-1cow).
|
|
3
|
+
*
|
|
4
|
+
* `SubtreeConnectionsView` in central annotates its rows with three deliberately
|
|
5
|
+
* distinct timestamps and says so in the source:
|
|
6
|
+
*
|
|
7
|
+
* last_synced_at the last run that actually brought data in
|
|
8
|
+
* last_attempt_at the last run of ANY outcome — so "it tried ten minutes ago
|
|
9
|
+
* and failed" is expressible, and a card cannot read
|
|
10
|
+
* "nothing yet" while it is in fact failing on a schedule
|
|
11
|
+
* last_checked_at when the CREDENTIAL was last verified, "a far more
|
|
12
|
+
* optimistic fact than either and never used as a stand-in"
|
|
13
|
+
*
|
|
14
|
+
* `listSources()` used to coalesce all three with `??`. Central sends
|
|
15
|
+
* `last_synced_at: null` for a connection that has never delivered a record, so
|
|
16
|
+
* the chain fell straight through to `last_checked_at` and a connection whose
|
|
17
|
+
* KEY was verified five minutes ago rendered as data that ARRIVED five minutes
|
|
18
|
+
* ago. That is the one thing a freshness promise may not do.
|
|
19
|
+
*
|
|
20
|
+
* The tenant proxies this endpoint VERBATIM — `tenant-starter`'s
|
|
21
|
+
* `ConnectionsView` returns `SubtreeClient.connections()` untouched, and that
|
|
22
|
+
* client is a plain `json.loads` of the central body — so every field asserted
|
|
23
|
+
* below really is on the wire this adapter reads, in the snake_case central
|
|
24
|
+
* wrote it. `@startsimpli/api`'s fetch wrapper camelCases response keys in the
|
|
25
|
+
* browser and not from a server-side fetch, which is why every field is read in
|
|
26
|
+
* BOTH spellings and both spellings are exercised here.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, expect, it } from 'vitest';
|
|
29
|
+
import { render, screen } from '@testing-library/react';
|
|
30
|
+
|
|
31
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
32
|
+
import { createCorpusApi } from '../corpus/client';
|
|
33
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
34
|
+
import { FIXTURE_ORG } from '../corpus/fixtures';
|
|
35
|
+
|
|
36
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
37
|
+
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
38
|
+
|
|
39
|
+
// Anchored on the real clock so the staleness window is decided by the offset
|
|
40
|
+
// and never by what day the suite happens to run.
|
|
41
|
+
const NOW = Date.now();
|
|
42
|
+
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
|
43
|
+
const FIVE_MINUTES_AGO = ago(5 * 60_000);
|
|
44
|
+
const TEN_MINUTES_AGO = ago(10 * 60_000);
|
|
45
|
+
const AN_HOUR_AGO = ago(60 * 60_000);
|
|
46
|
+
const A_WEEK_AGO = ago(7 * 24 * 60 * 60_000);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The WIRE, not the contract. Rows go in exactly as central serialises them so a
|
|
50
|
+
* fixture can never be more generous than the server that feeds it.
|
|
51
|
+
*/
|
|
52
|
+
function httpOver(rows: Array<Record<string, unknown>>): CorpusHttp {
|
|
53
|
+
return {
|
|
54
|
+
async get<T>(path: string): Promise<T> {
|
|
55
|
+
if (path.includes('connections')) {
|
|
56
|
+
return { count: rows.length, next: null, previous: null, results: rows } as T;
|
|
57
|
+
}
|
|
58
|
+
return EMPTY_PAGE as T;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sourcesFrom(rows: Array<Record<string, unknown>>, staleAfterHours = 26) {
|
|
64
|
+
const api = createCorpusApi({ http: httpOver(rows), scope: SCOPE, staleAfterHours });
|
|
65
|
+
return api.listSources();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A connected row with nothing wrong with it and nothing delivered by it. */
|
|
69
|
+
const CONNECTED = {
|
|
70
|
+
id: 'c1',
|
|
71
|
+
connector_key: 'granola',
|
|
72
|
+
connector_name: 'Granola',
|
|
73
|
+
status: 'connected',
|
|
74
|
+
check_error: '',
|
|
75
|
+
last_synced_at: null,
|
|
76
|
+
last_attempt_at: null,
|
|
77
|
+
last_checked_at: null,
|
|
78
|
+
consecutive_failures: 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe('listSources — a verified credential is not delivered data', () => {
|
|
82
|
+
it('reports never for a connection whose key checked out but which has never synced', async () => {
|
|
83
|
+
const [source] = await sourcesFrom([
|
|
84
|
+
{ ...CONNECTED, last_checked_at: FIVE_MINUTES_AGO },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
expect(source.status).toBe('never');
|
|
88
|
+
// The stand-in central forbids: `last_checked_at` may not become the sync
|
|
89
|
+
// time. Asserting the status alone would let the timestamp keep leaking into
|
|
90
|
+
// the visible "synced 5 min ago" line.
|
|
91
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
92
|
+
expect(source.lastCheckedAt).toBe(FIVE_MINUTES_AGO);
|
|
93
|
+
expect(source.detail).toMatch(/nothing has arrived/i);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports never in the camelCase spelling too, which is what the browser sees', async () => {
|
|
97
|
+
const [source] = await sourcesFrom([
|
|
98
|
+
{
|
|
99
|
+
id: 'c1',
|
|
100
|
+
connectorKey: 'granola',
|
|
101
|
+
connectorName: 'Granola',
|
|
102
|
+
status: 'connected',
|
|
103
|
+
checkError: '',
|
|
104
|
+
lastSyncedAt: null,
|
|
105
|
+
lastAttemptAt: null,
|
|
106
|
+
lastCheckedAt: FIVE_MINUTES_AGO,
|
|
107
|
+
consecutiveFailures: 0,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
expect(source.status).toBe('never');
|
|
112
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
113
|
+
expect(source.lastCheckedAt).toBe(FIVE_MINUTES_AGO);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('keeps the three facts apart when all three are present and different', async () => {
|
|
117
|
+
const [source] = await sourcesFrom([
|
|
118
|
+
{
|
|
119
|
+
...CONNECTED,
|
|
120
|
+
last_synced_at: AN_HOUR_AGO,
|
|
121
|
+
last_attempt_at: AN_HOUR_AGO,
|
|
122
|
+
last_checked_at: FIVE_MINUTES_AGO,
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(source.lastSyncedAt).toBe(AN_HOUR_AGO);
|
|
127
|
+
expect(source.lastAttemptAt).toBe(AN_HOUR_AGO);
|
|
128
|
+
expect(source.lastCheckedAt).toBe(FIVE_MINUTES_AGO);
|
|
129
|
+
expect(source.status).toBe('ok');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('measures staleness against the SYNC, never against the credential check', async () => {
|
|
133
|
+
const [source] = await sourcesFrom([
|
|
134
|
+
{
|
|
135
|
+
...CONNECTED,
|
|
136
|
+
last_synced_at: A_WEEK_AGO,
|
|
137
|
+
last_attempt_at: A_WEEK_AGO,
|
|
138
|
+
// Checked minutes ago and still a week stale — the credential being fine
|
|
139
|
+
// is exactly why nobody noticed.
|
|
140
|
+
last_checked_at: FIVE_MINUTES_AGO,
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
expect(source.status).toBe('stale');
|
|
145
|
+
expect(source.lastSyncedAt).toBe(A_WEEK_AGO);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('listSources — it tried and it failed', () => {
|
|
150
|
+
it('does not say "nothing yet" for a connection that is failing on a schedule', async () => {
|
|
151
|
+
const [source] = await sourcesFrom([
|
|
152
|
+
{
|
|
153
|
+
...CONNECTED,
|
|
154
|
+
last_synced_at: null,
|
|
155
|
+
// A run FINISHED ten minutes ago and none has ever finished well, which
|
|
156
|
+
// is exactly how central expresses a failed run.
|
|
157
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
158
|
+
last_checked_at: FIVE_MINUTES_AGO,
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
expect(source.status).toBe('error');
|
|
163
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
164
|
+
expect(source.lastAttemptAt).toBe(TEN_MINUTES_AGO);
|
|
165
|
+
expect(source.detail).toMatch(/sync/i);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('calls a run of consecutive failures failing even after a good sync', async () => {
|
|
169
|
+
const [source] = await sourcesFrom([
|
|
170
|
+
{
|
|
171
|
+
...CONNECTED,
|
|
172
|
+
last_synced_at: AN_HOUR_AGO,
|
|
173
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
174
|
+
consecutive_failures: 3,
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
expect(source.status).toBe('error');
|
|
179
|
+
expect(source.lastSyncedAt).toBe(AN_HOUR_AGO);
|
|
180
|
+
expect(source.consecutiveFailures).toBe(3);
|
|
181
|
+
expect(source.detail).toMatch(/3/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reads the failure counter in the camelCase spelling as well', async () => {
|
|
185
|
+
const [source] = await sourcesFrom([
|
|
186
|
+
{
|
|
187
|
+
id: 'c1',
|
|
188
|
+
connectorKey: 'granola',
|
|
189
|
+
connectorName: 'Granola',
|
|
190
|
+
status: 'connected',
|
|
191
|
+
lastSyncedAt: AN_HOUR_AGO,
|
|
192
|
+
lastAttemptAt: TEN_MINUTES_AGO,
|
|
193
|
+
consecutiveFailures: 2,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
expect(source.status).toBe('error');
|
|
198
|
+
expect(source.consecutiveFailures).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('still lets the server’s own error text win, in the words the server used', async () => {
|
|
202
|
+
const [source] = await sourcesFrom([
|
|
203
|
+
{
|
|
204
|
+
...CONNECTED,
|
|
205
|
+
status: 'needs_reauth',
|
|
206
|
+
check_error: 'The vendor rejected this key (401). Reconnect Granola.',
|
|
207
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
208
|
+
consecutive_failures: 4,
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
expect(source.status).toBe('error');
|
|
213
|
+
expect(source.detail).toBe('The vendor rejected this key (401). Reconnect Granola.');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('says nothing has arrived when nothing has ever even been tried', async () => {
|
|
217
|
+
const [source] = await sourcesFrom([{ ...CONNECTED }]);
|
|
218
|
+
|
|
219
|
+
expect(source.status).toBe('never');
|
|
220
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
221
|
+
expect(source.lastAttemptAt).toBeNull();
|
|
222
|
+
expect(source.detail).toBe('Connected, but nothing has arrived yet.');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('the Library strip, rendered', () => {
|
|
227
|
+
/**
|
|
228
|
+
* The bug was VISIBLE, so one assertion is made where the reader would have
|
|
229
|
+
* seen it: the strip prints `synced {relative(lastSyncedAt)}`, and a
|
|
230
|
+
* never-synced connection with a freshly verified key used to render "synced
|
|
231
|
+
* 5 min ago" over an empty corpus.
|
|
232
|
+
*/
|
|
233
|
+
it('does not report a never-synced source as recently synced', async () => {
|
|
234
|
+
const http = httpOver([{ ...CONNECTED, last_checked_at: FIVE_MINUTES_AGO }]);
|
|
235
|
+
const api = createCorpusApi({ http, scope: SCOPE });
|
|
236
|
+
|
|
237
|
+
render(<LibraryPage api={api} scope={SCOPE} />);
|
|
238
|
+
|
|
239
|
+
expect(await screen.findByText('Nothing yet')).toBeInTheDocument();
|
|
240
|
+
expect(screen.getByText(/synced never/)).toBeInTheDocument();
|
|
241
|
+
expect(screen.queryByText(/synced (just now|\d)/)).not.toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
});
|
package/src/api/connections.ts
CHANGED
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import type {
|
|
33
33
|
Connection,
|
|
34
|
+
ConnectionInstructions,
|
|
34
35
|
ConnectionPermissions,
|
|
35
36
|
ConnectionRun,
|
|
36
37
|
ConnectorCatalogEntry,
|
|
37
38
|
MemberConnectionsApi,
|
|
39
|
+
OAuthStart,
|
|
38
40
|
} from '../components/pages/types';
|
|
39
41
|
import { request } from './_fetch';
|
|
40
42
|
|
|
@@ -87,6 +89,50 @@ export const connectionsMe: MemberConnectionsApi = {
|
|
|
87
89
|
return request<Connection>(`${BASE}/`, { method: 'POST', body: input });
|
|
88
90
|
},
|
|
89
91
|
|
|
92
|
+
/**
|
|
93
|
+
* START a consent round trip on an OAuth connector (bd startsim-768w.22.25.21).
|
|
94
|
+
*
|
|
95
|
+
* ONE call, not create-then-initiate. The two halves have no independent
|
|
96
|
+
* meaning — a shell card with no consent URL is an orphan the member cannot
|
|
97
|
+
* act on, and a consent URL with no card has nowhere to put the token — so a
|
|
98
|
+
* refusal here leaves nothing behind.
|
|
99
|
+
*
|
|
100
|
+
* Takes `connector_key` rather than an id because the row does not exist yet;
|
|
101
|
+
* that is the whole bug this closes. The caller opens `authorization_url` and
|
|
102
|
+
* does nothing else: it is already complete, and the client has no `client_id`
|
|
103
|
+
* of its own to add to it.
|
|
104
|
+
*
|
|
105
|
+
* `return_to` is where the browser should land afterwards. It is VALIDATED
|
|
106
|
+
* server-side against this tenant's own recorded origins and folded into the
|
|
107
|
+
* signed state — the callback is an anonymous browser mid-redirect from the
|
|
108
|
+
* vendor and cannot be allowed to name its own destination.
|
|
109
|
+
*/
|
|
110
|
+
startOAuth(input: {
|
|
111
|
+
connector_key: string;
|
|
112
|
+
name?: string;
|
|
113
|
+
config?: Record<string, unknown>;
|
|
114
|
+
capabilities_enabled?: string[];
|
|
115
|
+
return_to?: string;
|
|
116
|
+
}): Promise<OAuthStart> {
|
|
117
|
+
return request<OAuthStart>(`${BASE}/oauth/start/`, { method: 'POST', body: input });
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* RE-consent an existing card whose grant the vendor stopped accepting. The
|
|
122
|
+
* first connect is `startOAuth`; this one takes an id because the card is
|
|
123
|
+
* already there and must not be duplicated.
|
|
124
|
+
*/
|
|
125
|
+
oauthInitiate(id: string, input?: { return_to?: string }): Promise<OAuthStart> {
|
|
126
|
+
return request<OAuthStart>(`${BASE}/${id}/oauth/initiate/`, {
|
|
127
|
+
method: 'POST', body: input ?? {},
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/** Vendor-side setup steps, as DATA written by the connector in Python. */
|
|
132
|
+
connectionInstructions(id: string): Promise<ConnectionInstructions> {
|
|
133
|
+
return request<ConnectionInstructions>(`${BASE}/${id}/instructions/`);
|
|
134
|
+
},
|
|
135
|
+
|
|
90
136
|
/**
|
|
91
137
|
* Test it now, against the STORED credential. The body is deliberately empty:
|
|
92
138
|
* a "test connection" button that accepts a pasted key is a second connect
|
|
@@ -123,6 +123,16 @@ interface BoundApi {
|
|
|
123
123
|
}): Promise<ConnectionInvokeResult>;
|
|
124
124
|
/** Per-member governance (bd startsim-768w.22.25.8). Foundry mode only, and
|
|
125
125
|
absent on an api that predates the endpoints — see `ConnectionsApi`. */
|
|
126
|
+
/** The OAuth hand-off (bd startsim-768w.22.25.14/.23). Optional: an app on an
|
|
127
|
+
older client keeps type-checking, and the card renders the connector as
|
|
128
|
+
not-connectable-here rather than offering a button that cannot fire. */
|
|
129
|
+
startOAuth?(input: {
|
|
130
|
+
connector_key: string;
|
|
131
|
+
name?: string;
|
|
132
|
+
config?: Record<string, unknown>;
|
|
133
|
+
capabilities_enabled?: string[];
|
|
134
|
+
return_to?: string;
|
|
135
|
+
}): Promise<{ authorization_url: string; state: string; redirect_uri: string }>;
|
|
126
136
|
setMemberConnectorPolicy?(connectorKey: string, allowed: boolean): Promise<unknown>;
|
|
127
137
|
memberConnections?(): Promise<{
|
|
128
138
|
results: MemberConnectionRow[]; permissions: ConnectionPermissions;
|
|
@@ -144,6 +154,10 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
|
|
|
144
154
|
? { setMemberConnectorPolicy: (key: string, allowed: boolean) =>
|
|
145
155
|
api.setMemberConnectorPolicy!(slug, key, allowed) }
|
|
146
156
|
: {};
|
|
157
|
+
const oauth = api.startOAuth
|
|
158
|
+
? { startOAuth: (input: Parameters<NonNullable<BoundApi['startOAuth']>>[0]) =>
|
|
159
|
+
api.startOAuth!(slug, input) }
|
|
160
|
+
: {};
|
|
147
161
|
const roster = api.memberConnections
|
|
148
162
|
? { memberConnections: () => api.memberConnections!(slug) }
|
|
149
163
|
: {};
|
|
@@ -153,6 +167,7 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
|
|
|
153
167
|
: {};
|
|
154
168
|
return {
|
|
155
169
|
...memberPolicy,
|
|
170
|
+
...oauth,
|
|
156
171
|
...roster,
|
|
157
172
|
...cutMember,
|
|
158
173
|
connectorCatalog: () => api.connectorCatalog(slug),
|
|
@@ -467,12 +482,76 @@ function CatalogCard({
|
|
|
467
482
|
// transcript bytes at all) and is unaffected by this box.
|
|
468
483
|
const [capabilities, setCapabilities] = React.useState<string[]>(entry.capabilities);
|
|
469
484
|
|
|
470
|
-
|
|
485
|
+
// THE ONE THING THIS CARD BRANCHES ON (bd startsim-768w.22.25.14/.23).
|
|
486
|
+
// `schema.auth_mode_for` derives it server-side and its docstring says so;
|
|
487
|
+
// this component had no reference to it at all, so an OAuth connector got the
|
|
488
|
+
// same credential form as everything else — which on the admin catalog meant
|
|
489
|
+
// the product asking a human to paste a refresh token, the exact thing
|
|
490
|
+
// `auth_mode` exists to prevent.
|
|
491
|
+
const isOAuth = entry.auth_mode === 'oauth';
|
|
492
|
+
// A stale client is not a dead button. The verb is optional on both api
|
|
493
|
+
// shapes, so an app pinned to an older `@startsimpli/api` says the connector
|
|
494
|
+
// cannot be connected from here instead of rendering a hand-off that throws.
|
|
495
|
+
const canHandOff = isOAuth && typeof api.startOAuth === 'function';
|
|
496
|
+
|
|
497
|
+
// NO credential fields in OAuth mode. Not hidden — never collected: the whole
|
|
498
|
+
// point is that the token comes back from the vendor, and a form that
|
|
499
|
+
// "helpfully" accepted one would keep the paste-a-refresh-token path alive
|
|
500
|
+
// beside the consent flow that replaced it.
|
|
501
|
+
const credentialFields = isOAuth ? [] : schemaFields(entry.credential_schema);
|
|
502
|
+
// Config IS still asked for, in both modes. It is the folder a Dropbox card
|
|
503
|
+
// will poll, it is not a secret, and dropping it here would mean asking for it
|
|
504
|
+
// again after the round trip.
|
|
471
505
|
const configFields = schemaFields(entry.config_schema);
|
|
472
506
|
const fields = [...credentialFields, ...configFields];
|
|
473
507
|
const warnings = patternWarnings(fields, values);
|
|
474
508
|
const missing = missingRequired(fields, values);
|
|
475
509
|
|
|
510
|
+
/** Collect the config fields the form did gather. Shared by both submits. */
|
|
511
|
+
function collectConfig(): Record<string, unknown> {
|
|
512
|
+
const config: Record<string, unknown> = {};
|
|
513
|
+
for (const spec of configFields) {
|
|
514
|
+
const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
|
|
515
|
+
if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
|
|
516
|
+
}
|
|
517
|
+
return config;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Hand the browser to the vendor (bd startsim-768w.22.25.21/.22).
|
|
522
|
+
*
|
|
523
|
+
* ONE call: the server writes the shell card AND returns the consent URL,
|
|
524
|
+
* because neither half is usable alone. The URL is opened VERBATIM — it is
|
|
525
|
+
* already complete, and this client has no `client_id` to add to it and must
|
|
526
|
+
* not have one to choose.
|
|
527
|
+
*
|
|
528
|
+
* `return_to` is where the member should land afterwards. It is sent, not
|
|
529
|
+
* trusted: the server validates it against this tenant's own recorded origins
|
|
530
|
+
* before folding it into the signed state, because the callback is an
|
|
531
|
+
* anonymous browser mid-redirect from the vendor's domain.
|
|
532
|
+
*
|
|
533
|
+
* `location.assign`, not a new tab. A vendor consent screen that opens in a
|
|
534
|
+
* popup is the one an ad blocker eats, and the whole point of the round trip
|
|
535
|
+
* is that the browser comes BACK here.
|
|
536
|
+
*/
|
|
537
|
+
async function handOff() {
|
|
538
|
+
setBusy(true);
|
|
539
|
+
setError('');
|
|
540
|
+
try {
|
|
541
|
+
const { authorization_url: url } = await api.startOAuth!({
|
|
542
|
+
connector_key: entry.key,
|
|
543
|
+
name: entry.name,
|
|
544
|
+
config: collectConfig(),
|
|
545
|
+
capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
|
|
546
|
+
return_to: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
547
|
+
});
|
|
548
|
+
if (typeof window !== 'undefined') window.location.assign(url);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
setError((err as { message?: string })?.message || 'Could not start sign-in.');
|
|
551
|
+
setBusy(false);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
476
555
|
async function submit() {
|
|
477
556
|
setBusy(true);
|
|
478
557
|
setError('');
|
|
@@ -566,18 +645,56 @@ function CatalogCard({
|
|
|
566
645
|
Connecting here adds a SECOND account.
|
|
567
646
|
</p>
|
|
568
647
|
) : null}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
648
|
+
{/* SIGN IN AT THE VENDOR — never a form asking for token material.
|
|
649
|
+
`auth_mode: oauth` means the credential schema is `access_token` /
|
|
650
|
+
`refresh_token` / `expires_at`, and rendering those as inputs asks
|
|
651
|
+
a human to go and mint a refresh token by hand. That is what this
|
|
652
|
+
card did to every admin who opened Dropbox. */}
|
|
653
|
+
{isOAuth ? (
|
|
654
|
+
<div className="space-y-2" data-oauth-connect={entry.key}>
|
|
655
|
+
<p className={cn.muted}>
|
|
656
|
+
{entry.name} signs you in on its own site. You will be sent there
|
|
657
|
+
to approve access and returned here — no keys to copy, and
|
|
658
|
+
StartSimpli never sees your password.
|
|
659
|
+
</p>
|
|
660
|
+
{!canHandOff ? (
|
|
661
|
+
<p className={cn.error}>
|
|
662
|
+
This app cannot start a {entry.name} sign-in yet. Update it, or
|
|
663
|
+
connect {entry.name} from the foundry control plane.
|
|
664
|
+
</p>
|
|
665
|
+
) : null}
|
|
666
|
+
</div>
|
|
667
|
+
) : (
|
|
668
|
+
<CredentialSchemaForm
|
|
669
|
+
fields={fields}
|
|
670
|
+
values={values}
|
|
671
|
+
warnings={warnings}
|
|
672
|
+
idPrefix={`connect-${entry.key}`}
|
|
673
|
+
onChange={(key, value) => {
|
|
674
|
+
const spec = fields.find((f) => f.key === key);
|
|
675
|
+
setValues((prev) => ({
|
|
676
|
+
...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
|
|
677
|
+
}));
|
|
678
|
+
}}
|
|
679
|
+
/>
|
|
680
|
+
)}
|
|
681
|
+
{/* Config is asked for in BOTH modes — it is not a secret, and it is
|
|
682
|
+
the folder this card will poll. Rendered separately in OAuth mode
|
|
683
|
+
because the credential half of the same form is gone. */}
|
|
684
|
+
{isOAuth && configFields.length > 0 ? (
|
|
685
|
+
<CredentialSchemaForm
|
|
686
|
+
fields={configFields}
|
|
687
|
+
values={values}
|
|
688
|
+
warnings={warnings}
|
|
689
|
+
idPrefix={`connect-${entry.key}`}
|
|
690
|
+
onChange={(key, value) => {
|
|
691
|
+
const spec = configFields.find((f) => f.key === key);
|
|
692
|
+
setValues((prev) => ({
|
|
693
|
+
...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
|
|
694
|
+
}));
|
|
695
|
+
}}
|
|
696
|
+
/>
|
|
697
|
+
) : null}
|
|
581
698
|
<CapabilityPicker
|
|
582
699
|
scope="catalog"
|
|
583
700
|
declared={entry.capabilities}
|
|
@@ -603,12 +720,19 @@ function CatalogCard({
|
|
|
603
720
|
className={cn.button}
|
|
604
721
|
disabled={
|
|
605
722
|
busy
|
|
723
|
+
// `missing` counts only the fields this mode actually collects.
|
|
724
|
+
// In OAuth mode that is config alone — gating the hand-off on a
|
|
725
|
+
// required `access_token` would disable the very button whose job
|
|
726
|
+
// is to go and get one.
|
|
606
727
|
|| missing.length > 0
|
|
728
|
+
|| (isOAuth && !canHandOff)
|
|
607
729
|
|| (entry.capabilities.length > 0 && capabilities.length === 0)
|
|
608
730
|
}
|
|
609
|
-
onClick={() => void submit()}
|
|
731
|
+
onClick={() => void (isOAuth ? handOff() : submit())}
|
|
610
732
|
>
|
|
611
|
-
{
|
|
733
|
+
{isOAuth
|
|
734
|
+
? (busy ? 'Redirecting…' : `Continue to ${entry.name}`)
|
|
735
|
+
: (busy ? 'Testing…' : 'Test and save')}
|
|
612
736
|
</button>
|
|
613
737
|
</div>
|
|
614
738
|
) : null}
|
|
@@ -47,6 +47,33 @@ export interface ConnectorSchema {
|
|
|
47
47
|
|
|
48
48
|
export type ConnectorSchemaLike = ConnectorSchema | ConnectorFieldSpec[] | null | undefined;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What `startOAuth`/`oauthInitiate` hand back (bd startsim-768w.22.25.22).
|
|
52
|
+
*
|
|
53
|
+
* `authorization_url` is COMPLETE — endpoint, `client_id`, `response_type`,
|
|
54
|
+
* `redirect_uri`, `state`, and whatever else the connector's own
|
|
55
|
+
* `authorization_params()` decided the vendor needs (Dropbox's includes
|
|
56
|
+
* `token_access_type=offline`, which is what makes the grant durable). The
|
|
57
|
+
* client opens it and adds nothing: it has no `client_id` of its own and must
|
|
58
|
+
* not have one to choose.
|
|
59
|
+
*
|
|
60
|
+
* `state` is opaque and signed. It is echoed by the vendor to the callback and
|
|
61
|
+
* is the only thing tying the redirect back to a connection; nothing in the
|
|
62
|
+
* browser needs to read it.
|
|
63
|
+
*/
|
|
64
|
+
export interface OAuthStart {
|
|
65
|
+
authorization_url: string;
|
|
66
|
+
state: string;
|
|
67
|
+
redirect_uri: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Vendor-side setup steps, written by the connector in Python. */
|
|
71
|
+
export interface ConnectionInstructions {
|
|
72
|
+
steps?: string[];
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export interface ConnectorCatalogEntry {
|
|
51
78
|
key: string;
|
|
52
79
|
name: string;
|
|
@@ -285,6 +312,22 @@ export interface ConnectionsApi {
|
|
|
285
312
|
setMemberConnectorPolicy?(slug: string, connectorKey: string, allowed: boolean):
|
|
286
313
|
Promise<{ connector_key: string; member_connect_allowed: boolean; changed: boolean }>;
|
|
287
314
|
|
|
315
|
+
/**
|
|
316
|
+
* START a consent round trip on an OAuth connector (bd startsim-768w.22.25.23).
|
|
317
|
+
*
|
|
318
|
+
* Optional so an app pinned to an older `@startsimpli/api` keeps compiling.
|
|
319
|
+
* The card branches on `auth_mode` AND on this verb being present, so a stale
|
|
320
|
+
* client renders the connector as not connectable from this screen rather
|
|
321
|
+
* than a button that cannot fire.
|
|
322
|
+
*/
|
|
323
|
+
startOAuth?(slug: string, input: {
|
|
324
|
+
connector_key: string;
|
|
325
|
+
name?: string;
|
|
326
|
+
config?: Record<string, unknown>;
|
|
327
|
+
capabilities_enabled?: string[];
|
|
328
|
+
return_to?: string;
|
|
329
|
+
}): Promise<OAuthStart>;
|
|
330
|
+
|
|
288
331
|
/** The roster: every member-owned connection in the foundry. Metadata only. */
|
|
289
332
|
memberConnections?(slug: string): Promise<{
|
|
290
333
|
results: MemberConnectionRow[];
|
|
@@ -357,4 +400,20 @@ export interface MemberConnectionsApi {
|
|
|
357
400
|
rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
|
|
358
401
|
disconnect(id: string): Promise<void>;
|
|
359
402
|
connectionRuns(id: string): Promise<{ results: ConnectionRun[] }>;
|
|
403
|
+
/**
|
|
404
|
+
* The OAuth half of the surface (bd startsim-768w.22.25.14/.21). Optional so
|
|
405
|
+
* an app pinned to an older `@startsimpli/api` still type-checks; the card
|
|
406
|
+
* branches on `auth_mode` and renders the hand-off only when both the mode
|
|
407
|
+
* says oauth AND the verb is present, so a stale client degrades to saying
|
|
408
|
+
* the connector cannot be connected here rather than to a dead button.
|
|
409
|
+
*/
|
|
410
|
+
startOAuth?(input: {
|
|
411
|
+
connector_key: string;
|
|
412
|
+
name?: string;
|
|
413
|
+
config?: Record<string, unknown>;
|
|
414
|
+
capabilities_enabled?: string[];
|
|
415
|
+
return_to?: string;
|
|
416
|
+
}): Promise<OAuthStart>;
|
|
417
|
+
oauthInitiate?(id: string, input?: { return_to?: string }): Promise<OAuthStart>;
|
|
418
|
+
connectionInstructions?(id: string): Promise<ConnectionInstructions>;
|
|
360
419
|
}
|
package/src/corpus/client.ts
CHANGED
|
@@ -221,6 +221,18 @@ function reportedFilters(query: CommitmentQuery, applied: string[]): string[] {
|
|
|
221
221
|
|
|
222
222
|
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* One wire timestamp, or `null`.
|
|
226
|
+
*
|
|
227
|
+
* ABSENT AND EMPTY BOTH MEAN NULL, and nothing else is coerced: a freshness fact
|
|
228
|
+
* that arrives as `""` from a serializer that stringifies its nulls must not
|
|
229
|
+
* become the string `""` and then a `NaN` date that reads as 1970. Everything
|
|
230
|
+
* that is not a non-empty string is simply "we were not told".
|
|
231
|
+
*/
|
|
232
|
+
function at(value: unknown): string | null {
|
|
233
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
224
236
|
/** Rows written in parallel. Small enough not to look like a burst to the tenant. */
|
|
225
237
|
const WRITE_CONCURRENCY = 6;
|
|
226
238
|
|
|
@@ -501,12 +513,25 @@ export function createCorpusApi(
|
|
|
501
513
|
const now = Date.now();
|
|
502
514
|
return (connectionPage.results ?? []).map((connection) => {
|
|
503
515
|
const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
516
|
+
// THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
|
|
517
|
+
// chain ending in `last_checked_at`, and central's own view says of that
|
|
518
|
+
// column that it "is a far more optimistic fact than either and is never
|
|
519
|
+
// used as a stand-in". Central sends `last_synced_at: null` for a
|
|
520
|
+
// connection that has never delivered, so the chain fell straight
|
|
521
|
+
// through and a connection whose CREDENTIAL was verified five minutes
|
|
522
|
+
// ago rendered as data that ARRIVED five minutes ago — over an empty
|
|
523
|
+
// corpus. Each fact is now read into its own variable and only
|
|
524
|
+
// `lastSyncedAt` may answer "is my data current".
|
|
525
|
+
//
|
|
526
|
+
// BOTH SPELLINGS, for the same reason `ServerFilterReport` reads both:
|
|
527
|
+
// `@startsimpli/api` camelCases response keys in the browser and a plain
|
|
528
|
+
// server-side fetch does not, and getting this wrong on the timestamps
|
|
529
|
+
// is invisible until somebody's Library quietly lies.
|
|
530
|
+
const lastSyncedAt = at(connection.last_synced_at ?? connection.lastSyncedAt);
|
|
531
|
+
const lastAttemptAt = at(connection.last_attempt_at ?? connection.lastAttemptAt);
|
|
532
|
+
const lastCheckedAt = at(connection.last_checked_at ?? connection.lastCheckedAt);
|
|
533
|
+
const failures =
|
|
534
|
+
Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
|
|
510
535
|
const documentCount = Number(connection.document_count ?? connection.documentCount ?? 0);
|
|
511
536
|
const error = String(connection.check_error ?? connection.checkError ?? '');
|
|
512
537
|
const status = connection.status ?? '';
|
|
@@ -516,10 +541,27 @@ export function createCorpusApi(
|
|
|
516
541
|
if (error || status === 'error' || status === 'needs_reauth') {
|
|
517
542
|
health = 'error';
|
|
518
543
|
detail = error || `Connection status is ${String(status)}.`;
|
|
544
|
+
} else if (failures > 0 || (lastAttemptAt && !lastSyncedAt)) {
|
|
545
|
+
// IT TRIED AND IT FAILED. Central's view exists partly so that "a card
|
|
546
|
+
// cannot read 'nothing yet' while it is in fact failing on a
|
|
547
|
+
// schedule" — a run that FINISHED while nothing has ever finished well
|
|
548
|
+
// is a failed run, and a non-zero failure streak says so outright even
|
|
549
|
+
// when an older sync did land. Note what does NOT appear in this
|
|
550
|
+
// condition: `lastCheckedAt`. A verified key is not a delivered
|
|
551
|
+
// record, in either direction.
|
|
552
|
+
health = 'error';
|
|
553
|
+
detail =
|
|
554
|
+
failures > 0
|
|
555
|
+
? `${failures} sync${failures === 1 ? '' : 's'} in a row failed` +
|
|
556
|
+
`${lastSyncedAt ? '' : ' and nothing has arrived yet'}.`
|
|
557
|
+
: 'A sync ran but nothing arrived.';
|
|
519
558
|
} else if (!lastSyncedAt) {
|
|
559
|
+
// `never` regardless of `lastCheckedAt`, which is the whole bead.
|
|
520
560
|
health = 'never';
|
|
521
561
|
detail = 'Connected, but nothing has arrived yet.';
|
|
522
562
|
} else if (now - Date.parse(lastSyncedAt) > staleAfterHours * 3_600_000) {
|
|
563
|
+
// Measured against the SYNC. A credential re-checked this morning is
|
|
564
|
+
// exactly why a week-old corpus goes unnoticed.
|
|
523
565
|
health = 'stale';
|
|
524
566
|
detail = `Last sync was more than ${staleAfterHours} hours ago.`;
|
|
525
567
|
}
|
|
@@ -529,6 +571,9 @@ export function createCorpusApi(
|
|
|
529
571
|
label: String(connection.connector_name ?? connection.connectorName ?? sourceLabels[key] ?? key),
|
|
530
572
|
documentCount,
|
|
531
573
|
lastSyncedAt,
|
|
574
|
+
lastAttemptAt,
|
|
575
|
+
lastCheckedAt,
|
|
576
|
+
consecutiveFailures: failures,
|
|
532
577
|
status: health,
|
|
533
578
|
...(detail ? { detail } : {}),
|
|
534
579
|
};
|
package/src/corpus/types.ts
CHANGED
|
@@ -174,7 +174,31 @@ export interface SourceFreshness {
|
|
|
174
174
|
label: string;
|
|
175
175
|
/** How many documents this source has contributed to the corpus. */
|
|
176
176
|
documentCount: number;
|
|
177
|
+
/**
|
|
178
|
+
* THREE FACTS, AND CONFLATING ANY TWO OF THEM IS HOW A FRESHNESS STRIP LIES
|
|
179
|
+
* (bd startsim-1cow). Central's `SubtreeConnectionsView` computes them
|
|
180
|
+
* separately and says in its own source that the third "is never used as a
|
|
181
|
+
* stand-in"; this contract keeps them separate for the same reason.
|
|
182
|
+
*
|
|
183
|
+
* The last run that actually BROUGHT DATA IN. `null` means nothing ever has,
|
|
184
|
+
* and it stays `null` — a connection whose credential merely checked out is
|
|
185
|
+
* not a connection whose data arrived.
|
|
186
|
+
*/
|
|
177
187
|
lastSyncedAt: string | null;
|
|
188
|
+
/**
|
|
189
|
+
* The last run of ANY outcome. Present with a `null` `lastSyncedAt` means it
|
|
190
|
+
* TRIED AND FAILED, which is a different thing to say than "nothing yet" and
|
|
191
|
+
* needs a different action from the reader.
|
|
192
|
+
*/
|
|
193
|
+
lastAttemptAt?: string | null;
|
|
194
|
+
/**
|
|
195
|
+
* When the CREDENTIAL was last verified — far more optimistic than either of
|
|
196
|
+
* the above and never a substitute for them. Surfaced so a screen can say
|
|
197
|
+
* "the key is fine, the data is not", which is the useful sentence.
|
|
198
|
+
*/
|
|
199
|
+
lastCheckedAt?: string | null;
|
|
200
|
+
/** How many syncs in a row have failed. `0` (or absent) means none. */
|
|
201
|
+
consecutiveFailures?: number;
|
|
178
202
|
/**
|
|
179
203
|
* `ok` — synced recently and cleanly.
|
|
180
204
|
* `stale` — the last sync succeeded but was long enough ago to matter.
|