(
input: OAuthStartInput,
)
| 1042 | // start — begin a flow through a client to mint a connection. |
| 1043 | // ----------------------------------------------------------------------- |
| 1044 | const start = ( |
| 1045 | input: OAuthStartInput, |
| 1046 | ): Effect.Effect<ConnectResult, OAuthStartError | StorageFailure> => |
| 1047 | Effect.gen(function* () { |
| 1048 | const keys = yield* Effect.try({ |
| 1049 | try: () => deps.ownedKeys(input.owner), |
| 1050 | catch: (cause) => |
| 1051 | new StorageError({ |
| 1052 | message: "Cannot start OAuth flow for owner without a subject", |
| 1053 | cause, |
| 1054 | }), |
| 1055 | }); |
| 1056 | // Sharing is one-directional (org → members): a Workspace (org) connection |
| 1057 | // cannot be backed by a member's private (user) app. The connection owner |
| 1058 | // and the app owner are otherwise independent — a Personal connection |
| 1059 | // through a shared Workspace app is the supported cross-owner case. |
| 1060 | if (input.owner === "org" && input.clientOwner === "user") { |
| 1061 | return yield* new OAuthStartError({ |
| 1062 | message: "A Workspace connection must use a Workspace app.", |
| 1063 | }); |
| 1064 | } |
| 1065 | // Load the app by its EXPLICIT owner (the caller knows it — no derivation). |
| 1066 | // The connection is still minted under `input.owner`. Storage visibility |
| 1067 | // policy hides apps the actor cannot see, so a wrong owner yields null. |
| 1068 | const client = yield* loadClient(input.clientOwner, input.client); |
| 1069 | if (!client) { |
| 1070 | return yield* new OAuthStartError({ |
| 1071 | message: `OAuth client not found: ${input.client}`, |
| 1072 | }); |
| 1073 | } |
| 1074 | |
| 1075 | // newConnection: resolve the requested name to a FREE one against the |
| 1076 | // stored rows (not a client-side, policy-filtered view), so a second |
| 1077 | // untyped connect mints `personalGmail2` instead of silently re-minting |
| 1078 | // the first account's row. Reconnects omit the flag and keep targeting |
| 1079 | // their existing row. Bounded: a pathological owner with 1000 same-named |
| 1080 | // connections fails loudly rather than scanning forever. |
| 1081 | let name = input.name; |
| 1082 | if (input.newConnection === true) { |
| 1083 | let suffix = 2; |
| 1084 | while ( |
| 1085 | yield* deps.connectionNameTaken({ |
| 1086 | owner: input.owner, |
| 1087 | integration: input.integration, |
| 1088 | name, |
| 1089 | }) |
| 1090 | ) { |
| 1091 | if (suffix > 1000) { |
| 1092 | return yield* new OAuthStartError({ |
| 1093 | message: `No free connection name derivable from ${input.name}.`, |
| 1094 | }); |
| 1095 | } |
| 1096 | name = ConnectionName.make(`${String(input.name)}${suffix}`); |
| 1097 | suffix++; |
| 1098 | } |
| 1099 | } |
| 1100 | |
| 1101 | // Declared scopes win (driven by the selected auth template). MCP-style |
nothing calls this directly
no test coverage detected