(deps: OAuthServiceDeps)
| 670 | }); |
| 671 | |
| 672 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| 673 | const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; |
| 674 | const fetch = deps.fetch; |
| 675 | // Config-declared first-party apps, keyed by their prefixed slug. Config is |
| 676 | // the source of truth — no row exists, so every stored-row path (CRUD, GC) |
| 677 | // is bypassed by construction, and rotating a secret is an env change. |
| 678 | const firstPartyBySlug = new Map( |
| 679 | (deps.firstPartyClients ?? []).map((client) => [ |
| 680 | String(firstPartyOAuthClientSlug(client.name)), |
| 681 | client, |
| 682 | ]), |
| 683 | ); |
| 684 | // EXPLICIT — no localhost default. `null` means this executor has no OAuth |
| 685 | // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. |
| 686 | const redirectUri = deps.redirectUri; |
| 687 | const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy }; |
| 688 | |
| 689 | // ------------------------------------------------------------------------- |
| 690 | // Enterprise-managed rollout seam. |
| 691 | // |
| 692 | // ROLLOUT SEMANTIC, stated once here because it is the whole reason the gate |
| 693 | // sits where it does: the gate answers "may this connect attempt the |
| 694 | // enterprise-managed path", and nothing else. It runs once, before discovery, |
| 695 | // so a withheld verdict costs no round trip and spends no identity assertion. |
| 696 | // The verdict it produces is then FROZEN onto the connection |
| 697 | // (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`), and the credential-refresh path |
| 698 | // reads that state instead of re-asking. Turning the flag off therefore stops |
| 699 | // new enterprise-managed connects and leaves every existing one renewing — no |
| 700 | // stranded connections, no silent downgrade, and no third-party network |
| 701 | // dependency anywhere in credential resolution. |
| 702 | // ------------------------------------------------------------------------- |
| 703 | const rollout = deps.enterpriseManagedRollout; |
| 704 | |
| 705 | /** The gate's verdict, or "enabled" when no host injected a gate. */ |
| 706 | const decideEnterpriseManagedRollout = ( |
| 707 | context: EnterpriseManagedRolloutContext, |
| 708 | ): Effect.Effect<EnterpriseManagedRolloutDecision> => |
| 709 | rollout === undefined |
| 710 | ? Effect.succeed(ENTERPRISE_MANAGED_ROLLOUT_ENABLED) |
| 711 | : rollout.decide(context); |
| 712 | |
| 713 | /** Best-effort rollout observation. Failures AND defects are discarded here, |
| 714 | * so no implementation of `record` can fail a connect or change its outcome; |
| 715 | * keeping it off the critical path is the host's side of the contract. |
| 716 | * Mirrors how `afterCommit` treats `onIntegrationChange`. */ |
| 717 | const recordEnterpriseManagedRollout = ( |
| 718 | event: EnterpriseManagedRolloutEvent, |
| 719 | ): Effect.Effect<void> => |
| 720 | rollout === undefined |
| 721 | ? Effect.void |
| 722 | : rollout.record(event).pipe(Effect.ignoreCause({ log: false })); |
| 723 | |
| 724 | const filterAuthorizationCodeScopes = ( |
| 725 | client: LoadedOAuthClient, |
| 726 | requestedScopes: readonly string[], |
| 727 | ): Effect.Effect<readonly string[], never> => |
| 728 | Effect.gen(function* () { |
| 729 | if (requestedScopes.length === 0) return requestedScopes; |
no test coverage detected