(
input: OAuthStartInput,
)
| 1387 | // start — begin a flow through a client to mint a connection. |
| 1388 | // ----------------------------------------------------------------------- |
| 1389 | const start = ( |
| 1390 | input: OAuthStartInput, |
| 1391 | ): Effect.Effect<ConnectResult, OAuthStartError | StorageFailure> => |
| 1392 | Effect.gen(function* () { |
| 1393 | const keys = yield* Effect.try({ |
| 1394 | try: () => deps.ownedKeys(input.owner), |
| 1395 | catch: (cause) => |
| 1396 | new StorageError({ |
| 1397 | message: "Cannot start OAuth flow for owner without a subject", |
| 1398 | cause, |
| 1399 | }), |
| 1400 | }); |
| 1401 | // Sharing is one-directional (org → members): a Workspace (org) connection |
| 1402 | // cannot be backed by a member's private (user) app. The connection owner |
| 1403 | // and the app owner are otherwise independent — a Personal connection |
| 1404 | // through a shared Workspace app is the supported cross-owner case. |
| 1405 | // First-party apps are deployment-owned, outside the owner lattice |
| 1406 | // entirely, so the rule does not apply to them. |
| 1407 | const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client)); |
| 1408 | yield* Effect.annotateCurrentSpan({ |
| 1409 | "executor.oauth.client_first_party": firstPartyFlow, |
| 1410 | }); |
| 1411 | if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { |
| 1412 | return yield* new OAuthStartError({ |
| 1413 | message: "A Workspace connection must use a Workspace app.", |
| 1414 | }); |
| 1415 | } |
| 1416 | // Load the app by its EXPLICIT owner (the caller knows it — no derivation). |
| 1417 | // The connection is still minted under `input.owner`. Storage visibility |
| 1418 | // policy hides apps the actor cannot see, so a wrong owner yields null. |
| 1419 | const client = yield* loadClient(input.clientOwner, input.client); |
| 1420 | if (!client) { |
| 1421 | return yield* new OAuthStartError({ |
| 1422 | message: `OAuth client not found: ${input.client}`, |
| 1423 | }); |
| 1424 | } |
| 1425 | |
| 1426 | // Normalize the name the same way the mint stores it, so the free-name |
| 1427 | // guard below compares against the exact stored form. |
| 1428 | const requestedName = connectionIdentifier(String(input.name)); |
| 1429 | // newConnection: resolve the requested name to a FREE one against the |
| 1430 | // stored rows (not a client-side, policy-filtered view), so a second |
| 1431 | // untyped connect mints `personalGmail2` instead of silently re-minting |
| 1432 | // the first account's row. Reconnects omit the flag and keep targeting |
| 1433 | // their existing row. Bounded: a pathological owner with 1000 same-named |
| 1434 | // connections fails loudly rather than scanning forever. |
| 1435 | let name = requestedName; |
| 1436 | if (input.newConnection === true) { |
| 1437 | let suffix = 2; |
| 1438 | while ( |
| 1439 | yield* deps.connectionNameTaken({ |
| 1440 | owner: input.owner, |
| 1441 | integration: input.integration, |
| 1442 | name, |
| 1443 | }) |
| 1444 | ) { |
| 1445 | if (suffix > 1000) { |
| 1446 | return yield* new OAuthStartError({ |
nothing calls this directly
no test coverage detected