(
input: OAuthStartInput,
)
| 1644 | // start — begin a flow through a client to mint a connection. |
| 1645 | // ----------------------------------------------------------------------- |
| 1646 | const start = ( |
| 1647 | input: OAuthStartInput, |
| 1648 | ): Effect.Effect<ConnectResult, OAuthStartError | OrgWriteDeniedError | StorageFailure> => |
| 1649 | Effect.gen(function* () { |
| 1650 | // Gate before any session row or upstream exchange: minting a Workspace |
| 1651 | // connection (including a reconnect that would replace its credential) |
| 1652 | // is a workspace-level change. Personal connections remain member-owned. |
| 1653 | yield* deps.guardOrgWrite(input.owner); |
| 1654 | const keys = yield* Effect.try({ |
| 1655 | try: () => deps.ownedKeys(input.owner), |
| 1656 | catch: (cause) => |
| 1657 | new StorageError({ |
| 1658 | message: "Cannot start OAuth flow for owner without a subject", |
| 1659 | cause, |
| 1660 | }), |
| 1661 | }); |
| 1662 | // Sharing is one-directional (org → members): a Workspace (org) connection |
| 1663 | // cannot be backed by a member's private (user) app. The connection owner |
| 1664 | // and the app owner are otherwise independent — a Personal connection |
| 1665 | // through a shared Workspace app is the supported cross-owner case. |
| 1666 | // First-party apps are deployment-owned, outside the owner lattice |
| 1667 | // entirely, so the rule does not apply to them. |
| 1668 | const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client)); |
| 1669 | yield* Effect.annotateCurrentSpan({ |
| 1670 | "executor.oauth.client_first_party": firstPartyFlow, |
| 1671 | }); |
| 1672 | if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { |
| 1673 | return yield* new OAuthStartError({ |
| 1674 | message: "A Workspace connection must use a Workspace app.", |
| 1675 | }); |
| 1676 | } |
| 1677 | // Load the app by its EXPLICIT owner (the caller knows it — no derivation). |
| 1678 | // The connection is still minted under `input.owner`. Storage visibility |
| 1679 | // policy hides apps the actor cannot see, so a wrong owner yields null. |
| 1680 | const client = yield* loadClient(input.clientOwner, input.client); |
| 1681 | if (!client) { |
| 1682 | return yield* new OAuthStartError({ |
| 1683 | message: `OAuth client not found: ${input.client}`, |
| 1684 | }); |
| 1685 | } |
| 1686 | |
| 1687 | // Normalize the name the same way the mint stores it, so the free-name |
| 1688 | // guard below compares against the exact stored form. |
| 1689 | const requestedName = connectionIdentifier(String(input.name)); |
| 1690 | // newConnection: resolve the requested name to a FREE one against the |
| 1691 | // stored rows (not a client-side, policy-filtered view), so a second |
| 1692 | // untyped connect mints `personalGmail2` instead of silently re-minting |
| 1693 | // the first account's row. Reconnects omit the flag and keep targeting |
| 1694 | // their existing row. Bounded: a pathological owner with 1000 same-named |
| 1695 | // connections fails loudly rather than scanning forever. |
| 1696 | let name = requestedName; |
| 1697 | if (input.newConnection === true) { |
| 1698 | let suffix = 2; |
| 1699 | while ( |
| 1700 | yield* deps.connectionNameTaken({ |
| 1701 | owner: input.owner, |
| 1702 | integration: input.integration, |
| 1703 | name, |
nothing calls this directly
no test coverage detected