@startsimpli/integrations 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # @startsimpli/integrations
2
+
3
+ Provider-agnostic TypeScript client for the OAuth-backed **email** and **calendar** integrations that StartSimpli apps consume from the Django API. Ships:
4
+
5
+ - **Typed API clients** wrapping `start-simpli-api`'s `/auth/oauth/*`, `/auth/oauth-accounts/`, `/calendar/events/`, and `/email/messages/` endpoints
6
+ - **React Query hooks** for connected accounts, calendar events, and email messages — with built-in re-consent flow handling
7
+ - **Headless React UI primitives** for connect cards, account status, event/email lists, and re-consent prompts
8
+ - **Normalized error types** (`UnauthenticatedError`, `ReconsentRequiredError`, `ApiError`) so the UI can branch on auth failure modes without parsing payloads
9
+
10
+ ## The connections layer (foundries)
11
+
12
+ Alongside the email/calendar clients above, this package ships the UI for the
13
+ **connector framework** — the surface a foundry admin uses to connect a vendor
14
+ account and wire it up:
15
+
16
+ - **`CredentialSchemaForm`** — ONE renderer for every vendor, driven by the
17
+ catalog's own field schema (`{version, fields[]}`). It knows about FIELDS and
18
+ nothing about vendors: an unknown field type degrades to a text input, an
19
+ unrecognised schema version still renders its fields, and a field the server
20
+ marks `type: "secret"` / `write_only` is masked and never populated from a
21
+ read response.
22
+ - **`ConnectionsPage`** — the page composer: connected accounts, the catalog,
23
+ and the wiring flow (**discover → wire → confirm → fire**). Injected api, no
24
+ React context and no app-local provider, so an app mounts it in ten lines:
25
+
26
+ ```tsx
27
+ <ConnectionsPage api={apiClient.foundry} foundrySlug={slug} />
28
+ ```
29
+
30
+ **There are no per-vendor components, and a test enforces it** — it greps the
31
+ component tree for vendor keys. The backend refuses to hardcode a vendor (the
32
+ catalog is `DestinationProvider` rows and the field schemas come off the
33
+ connector class); that is worth nothing if the browser answers with
34
+ `if (key === 'n8n')`.
35
+
36
+ **OAuth providers shipped in-box:** `google` and `microsoft`. The Django backend brokers the OAuth handshake — this package never touches client secrets or refresh tokens directly. Reference lifecycle: `email-cal-srcs-dests`.
37
+
38
+ ## Install
39
+
40
+ Workspace-only:
41
+
42
+ ```jsonc
43
+ "dependencies": {
44
+ "@startsimpli/integrations": "workspace:*"
45
+ }
46
+ ```
47
+
48
+ Peer deps (both optional, only needed for the hooks/components subpaths):
49
+
50
+ - `react ^18 || ^19`
51
+ - `@tanstack/react-query ^5`
52
+
53
+ You can consume `@startsimpli/integrations/types` and `@startsimpli/integrations/api` from a non-React context (e.g. a Node server, an MSW handler) without pulling in React.
54
+
55
+ ## Public surface
56
+
57
+ | Export | Type | Description |
58
+ | --- | --- | --- |
59
+ | `OAuthProvider` | `'google' \| 'microsoft'` | The two providers backed by Django |
60
+ | `AccountStatus` | `'connected' \| 'disconnected' \| 'needs_reauth'` | Status union for `ConnectAccountCard` |
61
+ | `ConnectedAccount` / `CalendarEvent` / `CalendarEventAttendee` / `CalendarEventStatus` / `EmailMessage` / `PaginatedResponse<T>` / `CalendarWindow` / `ApiErrorPayload` | types | DRF response shapes |
62
+ | `ApiError` / `UnauthenticatedError` / `ReconsentRequiredError` | classes | Thrown by every API client; the UI uses `instanceof` to branch |
63
+ | `connectProvider(provider, redirectUri?)` | `POST /auth/oauth/{provider}/initiate/` | Returns the upstream `authorization_url` to redirect to |
64
+ | `revokeAccount(provider, oauthAccountId)` | `POST /auth/oauth/{provider}/revoke/` | Disconnects a connected account |
65
+ | `listConnectedAccounts()` | `GET /auth/oauth-accounts/` | All accounts for the current user |
66
+ | `listCalendarEvents({ from, to, provider?, calendar_id?, page?, page_size? })` | `GET /calendar/events/` | Paginated events in a window |
67
+ | `listEmailMessages({ provider?, account_id?, since?, is_outbound?, page?, page_size? })` | `GET /email/messages/` | Paginated messages |
68
+ | `setApiBase(url)` / `getApiBase()` | functions | Override the default `/api/v1` base URL |
69
+ | `setAuthTokenProvider(fn)` | function | Wire up a token source — injected as `Authorization: Bearer <token>` |
70
+ | `useConnectedAccounts(options?)` | hook | React Query wrapper around `listConnectedAccounts` (30 s `staleTime`) |
71
+ | `useDisconnectAccount()` | hook | Returns a mutator that revokes + invalidates the accounts query |
72
+ | `useCalendarEvents(window, options?)` | hook | Wraps `listCalendarEvents`; surfaces `events`, `reconsentUrl` |
73
+ | `useEmailMessages(filter?, options?)` | hook | Wraps `listEmailMessages`; surfaces `messages`, `reconsentUrl` |
74
+ | `useReconsent(error)` | hook | Extracts a `reconsent_url` from a thrown error + returns `openReconsent()` |
75
+ | `CONNECTED_ACCOUNTS_KEY` | const tuple | Stable React Query key for invalidation |
76
+ | `AccountStatusBadge` | component | "Connected / Needs re-auth / Disconnected" pill |
77
+ | `ConnectAccountCard` | component | Per-provider connect/disconnect card |
78
+ | `EmailList` / `EventList` / `EventListItem` | components | Headless list renderers |
79
+ | `ReconsentPromptCard` | component | Re-consent CTA card driven by `useReconsent` |
80
+
81
+ Subpath exports: `.`, `./types` (zero React), `./api` (zero React).
82
+
83
+ ### Adapters that ship today
84
+
85
+ | Provider | OAuth | Mail | Calendar |
86
+ | --- | :---: | :---: | :---: |
87
+ | **Google** | yes (`gmail.readonly`, `calendar.readonly`) | yes | yes |
88
+ | **Microsoft** | yes (`Mail.Read`, `Calendars.Read`) | yes | yes |
89
+
90
+ No other adapters (Apollo, Hunter, Stripe, …) live in this package — those concerns belong in their own packages or directly in the Django backend. The single source of truth for available providers is `OAuthProvider` in `src/types/index.ts`.
91
+
92
+ ## Configuration
93
+
94
+ This is a thin client over Django — all provider credentials (Google client ID/secret, Microsoft app registration, scopes, refresh-token vault) live in `start-simpli-api`. From the browser side, you only need to wire up:
95
+
96
+ | Setup call | Why |
97
+ | --- | --- |
98
+ | `setApiBase('https://api.example.com/api/v1')` | Point at your Django host. Defaults to `/api/v1` (relative). |
99
+ | `setAuthTokenProvider(() => getAccessToken())` | Provide a fresh JWT/session token for every request. Returning `null` falls back to cookie auth (the fetch wrapper uses `credentials: 'include'`). |
100
+
101
+ Errors surfaced from the Django API are normalized:
102
+
103
+ - **401** → `UnauthenticatedError`
104
+ - **403** with `reconsent_url` in the payload → `ReconsentRequiredError` (drives the re-consent prompt)
105
+ - All other non-2xx → `ApiError` with `status` + `payload`
106
+
107
+ ## Usage
108
+
109
+ Real example from `raise-simpli/web-app/src/app/(dashboard)/settings/integrations/page.tsx`:
110
+
111
+ ```tsx
112
+ 'use client'
113
+ import {
114
+ ConnectAccountCard,
115
+ useConnectedAccounts,
116
+ useDisconnectAccount,
117
+ type ConnectedAccount,
118
+ type OAuthProvider,
119
+ } from '@startsimpli/integrations'
120
+
121
+ const PROVIDERS: OAuthProvider[] = ['microsoft', 'google']
122
+
123
+ export default function IntegrationsPage() {
124
+ const { data: accounts = [], isLoading, error } = useConnectedAccounts()
125
+ const disconnect = useDisconnectAccount()
126
+
127
+ const accountByProvider = (provider: OAuthProvider): ConnectedAccount | undefined =>
128
+ accounts.find((a) => a.provider === provider)
129
+
130
+ return (
131
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
132
+ {PROVIDERS.map((provider) => (
133
+ <ConnectAccountCard
134
+ key={provider}
135
+ provider={provider}
136
+ account={accountByProvider(provider)}
137
+ onDisconnect={(id) => disconnect(provider, id)}
138
+ />
139
+ ))}
140
+ </div>
141
+ )
142
+ }
143
+ ```
144
+
145
+ Calendar window with re-consent handling:
146
+
147
+ ```tsx
148
+ const { events, reconsentUrl, isLoading } = useCalendarEvents({
149
+ from: startOfWeek,
150
+ to: endOfWeek,
151
+ }, { provider: 'google', pageSize: 200 })
152
+
153
+ if (reconsentUrl) return <ReconsentPromptCard href={reconsentUrl} />
154
+ ```
155
+
156
+ App startup wiring (called once):
157
+
158
+ ```ts
159
+ import { setApiBase, setAuthTokenProvider } from '@startsimpli/integrations'
160
+ import { getAccessToken } from '@startsimpli/auth/client'
161
+
162
+ setApiBase(process.env.NEXT_PUBLIC_API_BASE_URL!)
163
+ setAuthTokenProvider(() => getAccessToken())
164
+ ```
165
+
166
+ ## Verification
167
+
168
+ ```bash
169
+ cd packages/integrations
170
+ pnpm vitest run
171
+ pnpm tsc --noEmit
172
+ ```
173
+
174
+ The test suite (`src/__tests__/api.test.ts`) ships **10 tests** covering the OAuth/calendar/email clients and the 401 / 403-with-reconsent / 5xx error normalisation paths against an MSW-mocked Django backend.
175
+
176
+ ## Shared-first
177
+
178
+ Per CLAUDE.md **rule 9**, do **not** copy fetch helpers, types, or React Query hooks for calendar/email into an app's `src/`. The package guards this — `raise-simpli/web-app/src/__tests__/no_app_local_calendar_code.test.ts` exists to fail CI if app-local duplicates reappear. Extend `@startsimpli/integrations` instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -16,27 +16,36 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
- "scripts": {
20
- "type-check": "tsc --noEmit",
21
- "test": "vitest run",
22
- "test:watch": "vitest"
23
- },
24
19
  "dependencies": {},
25
20
  "peerDependencies": {
26
21
  "react": ">=18.0.0",
27
22
  "@tanstack/react-query": ">=5.0.0"
28
23
  },
29
24
  "peerDependenciesMeta": {
30
- "react": { "optional": true },
31
- "@tanstack/react-query": { "optional": true }
25
+ "react": {
26
+ "optional": true
27
+ },
28
+ "@tanstack/react-query": {
29
+ "optional": true
30
+ }
32
31
  },
33
32
  "devDependencies": {
34
33
  "@tanstack/react-query": "^5.0.0",
34
+ "@testing-library/jest-dom": "^6.6.3",
35
+ "@testing-library/react": "^16.1.0",
36
+ "@testing-library/user-event": "^14.5.2",
35
37
  "@types/node": "^22.0.0",
36
38
  "@types/react": "^19.0.0",
39
+ "jsdom": "^29.0.0",
37
40
  "msw": "^2.0.0",
38
41
  "react": "^19.0.0",
42
+ "react-dom": "^19.0.0",
39
43
  "typescript": "^6.0.0",
40
44
  "vitest": "^3.0.0"
45
+ },
46
+ "scripts": {
47
+ "type-check": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest"
41
50
  }
42
- }
51
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The connections layer: a generic renderer and a page composer
3
+ * (bd startsim-768w.22.3.2 RED, startsim-768w.22.3.4 GREEN).
4
+ *
5
+ * THE ONE RULE THIS SUITE EXISTS TO ENFORCE: no per-vendor components, ever.
6
+ * The backend already refuses to hardcode a vendor — the catalog is
7
+ * `DestinationProvider` rows and the field schemas come off the connector class
8
+ * — and that is worth nothing if the browser answers with `if (key === 'n8n')`.
9
+ * So every assertion below drives TWO different schemas through ONE component
10
+ * and checks the output differs only in the way the DATA differs.
11
+ *
12
+ * The composer is injected, not contextual: `<ConnectionsPage api={...}
13
+ * foundrySlug="..." />`, the same shape as `TeamSettingsPage`. No React context,
14
+ * no app-local provider — an app mounts it in ten lines and owns nothing but
15
+ * theme classNames.
16
+ */
17
+ import { describe, expect, it, vi } from 'vitest';
18
+ import { render, screen, waitFor } from '@testing-library/react';
19
+ import userEvent from '@testing-library/user-event';
20
+
21
+ import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
22
+ import { ConnectionsPage } from '../components/pages/ConnectionsPage';
23
+ import type { ConnectionsApi } from '../components/pages/types';
24
+
25
+ // --------------------------------------------------------------------------
26
+ // two schemas, one component
27
+ // --------------------------------------------------------------------------
28
+
29
+ /** Vendor A: a single secret. The Granola shape. */
30
+ const ONE_SECRET = [
31
+ { key: 'api_key', label: 'API key', type: 'secret', write_only: true, required: true,
32
+ help: 'Starts with grn_' },
33
+ ];
34
+
35
+ /** Vendor B: url + secret + select. The self-hosted n8n shape. */
36
+ const URL_SECRET_SELECT = [
37
+ { key: 'base_url', label: 'Instance URL', type: 'url', required: true,
38
+ placeholder: 'https://n8n.example.com' },
39
+ { key: 'api_key', label: 'Public API key', type: 'secret', write_only: true, required: false },
40
+ {
41
+ key: 'environment',
42
+ label: 'Environment',
43
+ type: 'select',
44
+ required: false,
45
+ default: 'production',
46
+ options: [
47
+ { value: 'production', label: 'Production' },
48
+ { value: 'staging', label: 'Staging' },
49
+ ],
50
+ },
51
+ ];
52
+
53
+ function renderForm(fields: unknown[], props: Record<string, unknown> = {}) {
54
+ const onChange = vi.fn();
55
+ const utils = render(
56
+ <CredentialSchemaForm
57
+ fields={fields as never}
58
+ values={{}}
59
+ onChange={onChange}
60
+ {...props}
61
+ />,
62
+ );
63
+ return { ...utils, onChange };
64
+ }
65
+
66
+ describe('CredentialSchemaForm — one renderer, every vendor', () => {
67
+ it('renders a one-secret schema from data alone', () => {
68
+ renderForm(ONE_SECRET);
69
+ expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
70
+ expect(screen.getByText(/Starts with grn_/i)).toBeInTheDocument();
71
+ });
72
+
73
+ it('renders a url+secret+select schema with the SAME component', () => {
74
+ renderForm(URL_SECRET_SELECT);
75
+ expect(screen.getByLabelText(/Instance URL/i)).toBeInTheDocument();
76
+ expect(screen.getByLabelText(/Public API key/i)).toBeInTheDocument();
77
+ expect(screen.getByLabelText(/Environment/i)).toBeInTheDocument();
78
+ expect(screen.getByRole('option', { name: 'Staging' })).toBeInTheDocument();
79
+ });
80
+
81
+ it('keeps the declared array order', () => {
82
+ const { container } = renderForm(URL_SECRET_SELECT);
83
+ const names = Array.from(container.querySelectorAll('[data-field]')).map(
84
+ (el) => el.getAttribute('data-field'),
85
+ );
86
+ expect(names).toEqual(['base_url', 'api_key', 'environment']);
87
+ });
88
+
89
+ it('honours placeholder and default', () => {
90
+ renderForm(URL_SECRET_SELECT);
91
+ expect(screen.getByPlaceholderText('https://n8n.example.com')).toBeInTheDocument();
92
+ expect((screen.getByLabelText(/Environment/i) as HTMLSelectElement).value)
93
+ .toBe('production');
94
+ });
95
+
96
+ it('masks a secret field', () => {
97
+ renderForm(ONE_SECRET);
98
+ expect(screen.getByLabelText(/API key/i)).toHaveAttribute('type', 'password');
99
+ });
100
+
101
+ it('NEVER populates a secret from a read response', () => {
102
+ // The read surface returns config, never credential values. If a `values`
103
+ // map ever carries one, the form still must not put it in the DOM — a
104
+ // masked input whose value is the real key is a copy-paste away from a leak.
105
+ renderForm(ONE_SECRET, { values: { api_key: 'grn_live_SHOULD_NOT_RENDER' } });
106
+ expect((screen.getByLabelText(/API key/i) as HTMLInputElement).value).toBe('');
107
+ expect(document.body.innerHTML).not.toContain('grn_live_SHOULD_NOT_RENDER');
108
+ });
109
+
110
+ it('degrades an unknown type to text and still renders its neighbours', () => {
111
+ renderForm([
112
+ { key: 'first', label: 'First' },
113
+ { key: 'exotic', label: 'Exotic', type: 'colour-wheel-from-the-future' },
114
+ { key: 'last', label: 'Last' },
115
+ ]);
116
+ expect(screen.getByLabelText(/Exotic/i)).toHaveAttribute('type', 'text');
117
+ expect(screen.getByLabelText(/First/i)).toBeInTheDocument();
118
+ expect(screen.getByLabelText(/Last/i)).toBeInTheDocument();
119
+ });
120
+
121
+ it('reports every edit through onChange', async () => {
122
+ const { onChange } = renderForm(ONE_SECRET);
123
+ await userEvent.type(screen.getByLabelText(/API key/i), 'grn_x');
124
+ expect(onChange).toHaveBeenCalled();
125
+ expect(onChange.mock.calls[0][0]).toBe('api_key');
126
+ });
127
+ });
128
+
129
+ // --------------------------------------------------------------------------
130
+ // the composer
131
+ // --------------------------------------------------------------------------
132
+
133
+ const CATALOG = {
134
+ results: [
135
+ {
136
+ key: 'n8n', name: 'n8n', description: 'Call a workflow', logo_url: '', docs_url: '',
137
+ capabilities: ['action', 'poll'], auth_mode: 'api_key',
138
+ credential_schema: { version: 1, fields: URL_SECRET_SELECT },
139
+ config_schema: { version: 1, fields: [] },
140
+ record_types: [], supports_webhooks: true,
141
+ availability: { available: true, reason: '' },
142
+ },
143
+ {
144
+ key: 'granola', name: 'Granola', description: 'Meeting notes', logo_url: '', docs_url: '',
145
+ capabilities: ['poll'], auth_mode: 'api_key',
146
+ credential_schema: { version: 1, fields: ONE_SECRET },
147
+ config_schema: { version: 1, fields: [] },
148
+ record_types: [], supports_webhooks: false,
149
+ availability: { available: false, reason: 'ImportError: no granola sdk' },
150
+ },
151
+ ],
152
+ };
153
+
154
+ const CONNECTED = {
155
+ results: [
156
+ {
157
+ id: 'c1', public_id: 'pub1', name: 'Ops n8n', connector_key: 'n8n', connector_name: 'n8n',
158
+ external_account_id: '', account_label: 'https://n8n.example.com',
159
+ capabilities_enabled: ['action'], config: {}, status: 'connected',
160
+ health: 'ok', auth_mode: 'api_key',
161
+ },
162
+ ],
163
+ permissions: { view: true, manage: true, issue_tokens: true },
164
+ };
165
+
166
+ function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
167
+ return {
168
+ connectorCatalog: vi.fn().mockResolvedValue(CATALOG),
169
+ connections: vi.fn().mockResolvedValue(CONNECTED),
170
+ connect: vi.fn().mockResolvedValue(CONNECTED.results[0]),
171
+ checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
172
+ disconnect: vi.fn().mockResolvedValue(undefined),
173
+ connectionChoices: vi.fn().mockResolvedValue({
174
+ results: [
175
+ {
176
+ id: 'wf-1:node-a', label: 'Nightly digest — Webhook', kind: 'workflow',
177
+ selectable: true, reason: 'wirable', remediation: '', hidden_by_default: false,
178
+ detail: {},
179
+ },
180
+ {
181
+ id: 'wf-2', label: 'Schedule only', kind: 'workflow', selectable: false,
182
+ reason: 'not_wirable:schedule_only',
183
+ remediation: 'Add a Webhook trigger node.', hidden_by_default: false, detail: {},
184
+ },
185
+ ],
186
+ kind: 'workflow', cursor: null, has_more: false,
187
+ }),
188
+ wireTarget: vi.fn().mockResolvedValue({
189
+ id: 'd1', name: 'Nightly digest', provider_key: 'n8n', entity_type: 'contact',
190
+ is_active: true, verified: false,
191
+ destination_config: { webhook_url: 'https://n8n.example.com/webhook/nightly' },
192
+ }),
193
+ confirmTarget: vi.fn().mockResolvedValue({
194
+ id: 'd1', name: 'Nightly digest', provider_key: 'n8n', entity_type: 'contact',
195
+ is_active: true, verified: true,
196
+ destination_config: { webhook_url: 'https://n8n.example.com/webhook/nightly' },
197
+ }),
198
+ connectionActions: vi.fn().mockResolvedValue({
199
+ results: [{ name: 'trigger_workflow', label: 'Trigger a workflow', input_schema: {} }],
200
+ }),
201
+ invokeConnectionAction: vi.fn().mockResolvedValue({
202
+ ok: true, action: 'trigger_workflow', destination_id: 'd1', run_id: 'r1',
203
+ run_status: 'running', external_ref: null, output: {}, error: '',
204
+ }),
205
+ connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
206
+ ...overrides,
207
+ } as ConnectionsApi;
208
+ }
209
+
210
+ describe('ConnectionsPage — injected, not contextual', () => {
211
+ it('renders the catalog and the connected list from the injected api', async () => {
212
+ const api = stubApi();
213
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
214
+ expect(await screen.findByText('Ops n8n')).toBeInTheDocument();
215
+ expect(await screen.findByText('Granola')).toBeInTheDocument();
216
+ expect(api.connectorCatalog).toHaveBeenCalledWith('acme');
217
+ expect(api.connections).toHaveBeenCalledWith('acme');
218
+ });
219
+
220
+ it('shows an unavailable catalog row WITH its reason instead of hiding it', async () => {
221
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
222
+ expect(await screen.findByText(/no granola sdk/i)).toBeInTheDocument();
223
+ });
224
+
225
+ it('renders read-only when the permissions envelope says manage is false', async () => {
226
+ const api = stubApi({
227
+ connections: vi.fn().mockResolvedValue({
228
+ ...CONNECTED,
229
+ permissions: { view: true, manage: false, issue_tokens: false },
230
+ }),
231
+ });
232
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
233
+ await screen.findByText('Ops n8n');
234
+ expect(screen.queryByRole('button', { name: /^connect$/i })).not.toBeInTheDocument();
235
+ });
236
+
237
+ it('surfaces a DENIED read as a reason, not an empty page', async () => {
238
+ const denied = Object.assign(new Error('forbidden'), { status: 403 });
239
+ const api = stubApi({ connections: vi.fn().mockRejectedValue(denied) });
240
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
241
+ expect(await screen.findByText(/do not have access|not have permission/i)).toBeInTheDocument();
242
+ });
243
+
244
+ it('drives discovery -> wire -> confirm without anything hand-typed', async () => {
245
+ const api = stubApi();
246
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
247
+ await userEvent.click(await screen.findByRole('button', { name: /workflows|discover/i }));
248
+
249
+ // Nothing is dropped: the unpickable row is present and says why.
250
+ expect(await screen.findByText(/Nightly digest/)).toBeInTheDocument();
251
+ expect(screen.getByText(/Add a Webhook trigger node\./)).toBeInTheDocument();
252
+
253
+ await userEvent.click(screen.getByRole('button', { name: /^wire$/i }));
254
+ await waitFor(() => expect(api.wireTarget).toHaveBeenCalledWith('acme', 'c1', {
255
+ choice_id: 'wf-1:node-a',
256
+ }));
257
+
258
+ await userEvent.click(await screen.findByRole('button', { name: /confirm/i }));
259
+ await waitFor(() => expect(api.confirmTarget).toHaveBeenCalledWith('acme', 'c1', 'd1'));
260
+ });
261
+
262
+ it('will not offer to wire an unpickable choice', async () => {
263
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
264
+ await userEvent.click(await screen.findByRole('button', { name: /workflows|discover/i }));
265
+ await screen.findByText(/Schedule only/);
266
+ // One selectable row -> exactly one wire button.
267
+ expect(screen.getAllByRole('button', { name: /^wire$/i })).toHaveLength(1);
268
+ });
269
+ });
270
+
271
+ describe('the catalog envelope, as the server actually ships it', () => {
272
+ // REGRESSION. The first browser drive of this page died on
273
+ // `(entry.credential_schema ?? []) is not iterable`, because these fixtures
274
+ // were invented as bare arrays while `schema_to_wire` ships
275
+ // `{version, fields[]}`. Unit tests cannot catch a fixture that lies about
276
+ // the wire — only driving the real API can — so the shape is pinned here now.
277
+ it('reads fields out of the {version, fields} envelope', () => {
278
+ renderForm(ONE_SECRET);
279
+ expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
280
+ });
281
+
282
+ it('renders a catalog row whose schema arrives in the envelope', async () => {
283
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
284
+ expect(await screen.findByText('n8n')).toBeInTheDocument();
285
+ });
286
+
287
+ it('does NOT blank the form on a schema version this build has never seen', () => {
288
+ const { container } = render(
289
+ <CredentialSchemaForm
290
+ fields={schemaFields({ version: 7, fields: ONE_SECRET } as never)}
291
+ values={{}}
292
+ onChange={() => {}}
293
+ />,
294
+ );
295
+ expect(container.querySelectorAll('[data-field]')).toHaveLength(1);
296
+ });
297
+
298
+ it('treats a missing or malformed schema as no fields, not a crash', () => {
299
+ expect(schemaFields(null)).toEqual([]);
300
+ expect(schemaFields(undefined)).toEqual([]);
301
+ expect(schemaFields({} as never)).toEqual([]);
302
+ expect(schemaFields({ version: 1 } as never)).toEqual([]);
303
+ });
304
+
305
+ it('masks on type:"secret" — the server spelling, not a boolean', () => {
306
+ renderForm([{ key: 'k', label: 'Key', type: 'secret' }]);
307
+ expect(screen.getByLabelText(/Key/i)).toHaveAttribute('type', 'password');
308
+ });
309
+ });
310
+
311
+ describe('no per-vendor components', () => {
312
+ it('the connections layer names no vendor', async () => {
313
+ // The guard, as an assertion rather than a code-review habit. A component
314
+ // file whose text contains a vendor key is the thing the DB-driven catalog
315
+ // exists to prevent; the fixtures above prove two vendors already render
316
+ // through one path, and this stops the third from arriving as a branch.
317
+ const fs = await import('node:fs/promises');
318
+ const path = await import('node:path');
319
+ const dir = path.resolve(__dirname, '../components');
320
+ const offenders: string[] = [];
321
+ const walk = async (d: string): Promise<void> => {
322
+ for (const entry of await fs.readdir(d, { withFileTypes: true })) {
323
+ const full = path.join(d, entry.name);
324
+ if (entry.isDirectory()) { await walk(full); continue; }
325
+ if (!/\.tsx?$/.test(entry.name)) continue;
326
+ const text = await fs.readFile(full, 'utf8');
327
+ for (const vendor of ['n8n', 'granola', 'stripe', 'soundcharts', 'companies_house']) {
328
+ // A vendor named in a COMMENT is fine — it is an example. A vendor in
329
+ // code is a branch.
330
+ const code = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '');
331
+ if (code.toLowerCase().includes(vendor)) offenders.push(`${entry.name}: ${vendor}`);
332
+ }
333
+ }
334
+ };
335
+ await walk(dir);
336
+ expect(offenders).toEqual([]);
337
+ });
338
+ });