@startsimpli/integrations 0.2.24 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.24",
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",
@@ -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
+ });
@@ -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
- const credentialFields = schemaFields(entry.credential_schema);
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
- <CredentialSchemaForm
570
- fields={fields}
571
- values={values}
572
- warnings={warnings}
573
- idPrefix={`connect-${entry.key}`}
574
- onChange={(key, value) => {
575
- const spec = fields.find((f) => f.key === key);
576
- setValues((prev) => ({
577
- ...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
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
- {busy ? 'Testing…' : 'Test and save'}
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
  }