(
input: OAuthStartInput,
)
| 1134 | // start — begin a flow through a client to mint a connection. |
| 1135 | // ----------------------------------------------------------------------- |
| 1136 | const start = ( |
| 1137 | input: OAuthStartInput, |
| 1138 | ): Effect.Effect<ConnectResult, OAuthStartError | StorageFailure> => |
| 1139 | Effect.gen(function* () { |
| 1140 | const keys = yield* Effect.try({ |
| 1141 | try: () => deps.ownedKeys(input.owner), |
| 1142 | catch: (cause) => |
| 1143 | new StorageError({ |
| 1144 | message: "Cannot start OAuth flow for owner without a subject", |
| 1145 | cause, |
| 1146 | }), |
| 1147 | }); |
| 1148 | // Sharing is one-directional (org → members): a Workspace (org) connection |
| 1149 | // cannot be backed by a member's private (user) app. The connection owner |
| 1150 | // and the app owner are otherwise independent — a Personal connection |
| 1151 | // through a shared Workspace app is the supported cross-owner case. |
| 1152 | // First-party apps are deployment-owned, outside the owner lattice |
| 1153 | // entirely, so the rule does not apply to them. |
| 1154 | const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client)); |
| 1155 | yield* Effect.annotateCurrentSpan({ |
| 1156 | "executor.oauth.client_first_party": firstPartyFlow, |
| 1157 | }); |
| 1158 | if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { |
| 1159 | return yield* new OAuthStartError({ |
| 1160 | message: "A Workspace connection must use a Workspace app.", |
| 1161 | }); |
| 1162 | } |
| 1163 | // Load the app by its EXPLICIT owner (the caller knows it — no derivation). |
| 1164 | // The connection is still minted under `input.owner`. Storage visibility |
| 1165 | // policy hides apps the actor cannot see, so a wrong owner yields null. |
| 1166 | const client = yield* loadClient(input.clientOwner, input.client); |
| 1167 | if (!client) { |
| 1168 | return yield* new OAuthStartError({ |
| 1169 | message: `OAuth client not found: ${input.client}`, |
| 1170 | }); |
| 1171 | } |
| 1172 | |
| 1173 | // Normalize the name the same way the mint stores it, so the free-name |
| 1174 | // guard below compares against the exact stored form. |
| 1175 | const requestedName = connectionIdentifier(String(input.name)); |
| 1176 | // newConnection: resolve the requested name to a FREE one against the |
| 1177 | // stored rows (not a client-side, policy-filtered view), so a second |
| 1178 | // untyped connect mints `personalGmail2` instead of silently re-minting |
| 1179 | // the first account's row. Reconnects omit the flag and keep targeting |
| 1180 | // their existing row. Bounded: a pathological owner with 1000 same-named |
| 1181 | // connections fails loudly rather than scanning forever. |
| 1182 | let name = requestedName; |
| 1183 | if (input.newConnection === true) { |
| 1184 | let suffix = 2; |
| 1185 | while ( |
| 1186 | yield* deps.connectionNameTaken({ |
| 1187 | owner: input.owner, |
| 1188 | integration: input.integration, |
| 1189 | name, |
| 1190 | }) |
| 1191 | ) { |
| 1192 | if (suffix > 1000) { |
| 1193 | return yield* new OAuthStartError({ |
nothing calls this directly
no test coverage detected