@open-everywhere/adapters 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +43 -0
  4. package/agent/aws/cli.ts +121 -0
  5. package/agent/aws/config.ts +71 -0
  6. package/agent/aws/register.ts +65 -0
  7. package/agent/aws/sso.ts +567 -0
  8. package/agent/aws/sts.ts +191 -0
  9. package/agent/blob/hosting.ts +24 -0
  10. package/agent/browser/config.ts +62 -0
  11. package/agent/browser/register.ts +66 -0
  12. package/agent/browser/sandbox.ts +39 -0
  13. package/agent/contributions.ts +12 -0
  14. package/agent/directory/config.ts +138 -0
  15. package/agent/directory/okta.ts +226 -0
  16. package/agent/directory/register.ts +16 -0
  17. package/agent/instrumentation.ts +42 -0
  18. package/agent/model/approval-judge.ts +57 -0
  19. package/agent/model/dream-judge.ts +105 -0
  20. package/agent/model/gateway-vision.ts +162 -0
  21. package/agent/model/gateway.ts +85 -0
  22. package/agent/oe-oauth/config.ts +246 -0
  23. package/agent/oe-oauth/errors.ts +77 -0
  24. package/agent/oe-oauth/oauth-client.ts +376 -0
  25. package/agent/oe-oauth/pkce.ts +21 -0
  26. package/agent/oe-oauth/register.ts +32 -0
  27. package/agent/oe-oauth/token-vault.ts +652 -0
  28. package/agent/oe-oauth/user-auth-flow.ts +76 -0
  29. package/agent/postgres/neon.ts +108 -0
  30. package/agent/sandbox/backend-stop.ts +259 -0
  31. package/agent/sandbox/created-sandbox-registry.ts +27 -0
  32. package/agent/sandbox/extend-workspace-time.ts +77 -0
  33. package/agent/sandbox/vercel-lifecycle.ts +252 -0
  34. package/dist/agent/aws/cli.d.ts +24 -0
  35. package/dist/agent/aws/cli.d.ts.map +1 -0
  36. package/dist/agent/aws/cli.js +89 -0
  37. package/dist/agent/aws/cli.js.map +1 -0
  38. package/dist/agent/aws/config.d.ts +27 -0
  39. package/dist/agent/aws/config.d.ts.map +1 -0
  40. package/dist/agent/aws/config.js +46 -0
  41. package/dist/agent/aws/config.js.map +1 -0
  42. package/dist/agent/aws/register.d.ts +23 -0
  43. package/dist/agent/aws/register.d.ts.map +1 -0
  44. package/dist/agent/aws/register.js +39 -0
  45. package/dist/agent/aws/register.js.map +1 -0
  46. package/dist/agent/aws/sso.d.ts +109 -0
  47. package/dist/agent/aws/sso.d.ts.map +1 -0
  48. package/dist/agent/aws/sso.js +421 -0
  49. package/dist/agent/aws/sso.js.map +1 -0
  50. package/dist/agent/aws/sts.d.ts +27 -0
  51. package/dist/agent/aws/sts.d.ts.map +1 -0
  52. package/dist/agent/aws/sts.js +135 -0
  53. package/dist/agent/aws/sts.js.map +1 -0
  54. package/dist/agent/blob/hosting.d.ts +4 -0
  55. package/dist/agent/blob/hosting.d.ts.map +1 -0
  56. package/dist/agent/blob/hosting.js +20 -0
  57. package/dist/agent/blob/hosting.js.map +1 -0
  58. package/dist/agent/browser/config.d.ts +24 -0
  59. package/dist/agent/browser/config.d.ts.map +1 -0
  60. package/dist/agent/browser/config.js +50 -0
  61. package/dist/agent/browser/config.js.map +1 -0
  62. package/dist/agent/browser/register.d.ts +7 -0
  63. package/dist/agent/browser/register.d.ts.map +1 -0
  64. package/dist/agent/browser/register.js +47 -0
  65. package/dist/agent/browser/register.js.map +1 -0
  66. package/dist/agent/browser/sandbox.d.ts +23 -0
  67. package/dist/agent/browser/sandbox.d.ts.map +1 -0
  68. package/dist/agent/browser/sandbox.js +27 -0
  69. package/dist/agent/browser/sandbox.js.map +1 -0
  70. package/dist/agent/contributions.d.ts +16 -0
  71. package/dist/agent/contributions.d.ts.map +1 -0
  72. package/dist/agent/contributions.js +11 -0
  73. package/dist/agent/contributions.js.map +1 -0
  74. package/dist/agent/directory/config.d.ts +39 -0
  75. package/dist/agent/directory/config.d.ts.map +1 -0
  76. package/dist/agent/directory/config.js +101 -0
  77. package/dist/agent/directory/config.js.map +1 -0
  78. package/dist/agent/directory/okta.d.ts +19 -0
  79. package/dist/agent/directory/okta.d.ts.map +1 -0
  80. package/dist/agent/directory/okta.js +178 -0
  81. package/dist/agent/directory/okta.js.map +1 -0
  82. package/dist/agent/directory/register.d.ts +6 -0
  83. package/dist/agent/directory/register.d.ts.map +1 -0
  84. package/dist/agent/directory/register.js +16 -0
  85. package/dist/agent/directory/register.js.map +1 -0
  86. package/dist/agent/instrumentation.d.ts +5 -0
  87. package/dist/agent/instrumentation.d.ts.map +1 -0
  88. package/dist/agent/instrumentation.js +39 -0
  89. package/dist/agent/instrumentation.js.map +1 -0
  90. package/dist/agent/model/approval-judge.d.ts +15 -0
  91. package/dist/agent/model/approval-judge.d.ts.map +1 -0
  92. package/dist/agent/model/approval-judge.js +46 -0
  93. package/dist/agent/model/approval-judge.js.map +1 -0
  94. package/dist/agent/model/dream-judge.d.ts +40 -0
  95. package/dist/agent/model/dream-judge.d.ts.map +1 -0
  96. package/dist/agent/model/dream-judge.js +80 -0
  97. package/dist/agent/model/dream-judge.js.map +1 -0
  98. package/dist/agent/model/gateway-vision.d.ts +53 -0
  99. package/dist/agent/model/gateway-vision.d.ts.map +1 -0
  100. package/dist/agent/model/gateway-vision.js +129 -0
  101. package/dist/agent/model/gateway-vision.js.map +1 -0
  102. package/dist/agent/model/gateway.d.ts +30 -0
  103. package/dist/agent/model/gateway.d.ts.map +1 -0
  104. package/dist/agent/model/gateway.js +55 -0
  105. package/dist/agent/model/gateway.js.map +1 -0
  106. package/dist/agent/oe-oauth/config.d.ts +69 -0
  107. package/dist/agent/oe-oauth/config.d.ts.map +1 -0
  108. package/dist/agent/oe-oauth/config.js +182 -0
  109. package/dist/agent/oe-oauth/config.js.map +1 -0
  110. package/dist/agent/oe-oauth/errors.d.ts +63 -0
  111. package/dist/agent/oe-oauth/errors.d.ts.map +1 -0
  112. package/dist/agent/oe-oauth/errors.js +73 -0
  113. package/dist/agent/oe-oauth/errors.js.map +1 -0
  114. package/dist/agent/oe-oauth/oauth-client.d.ts +124 -0
  115. package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -0
  116. package/dist/agent/oe-oauth/oauth-client.js +262 -0
  117. package/dist/agent/oe-oauth/oauth-client.js.map +1 -0
  118. package/dist/agent/oe-oauth/pkce.d.ts +8 -0
  119. package/dist/agent/oe-oauth/pkce.d.ts.map +1 -0
  120. package/dist/agent/oe-oauth/pkce.js +18 -0
  121. package/dist/agent/oe-oauth/pkce.js.map +1 -0
  122. package/dist/agent/oe-oauth/register.d.ts +17 -0
  123. package/dist/agent/oe-oauth/register.d.ts.map +1 -0
  124. package/dist/agent/oe-oauth/register.js +24 -0
  125. package/dist/agent/oe-oauth/register.js.map +1 -0
  126. package/dist/agent/oe-oauth/token-vault.d.ts +107 -0
  127. package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -0
  128. package/dist/agent/oe-oauth/token-vault.js +498 -0
  129. package/dist/agent/oe-oauth/token-vault.js.map +1 -0
  130. package/dist/agent/oe-oauth/user-auth-flow.d.ts +10 -0
  131. package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -0
  132. package/dist/agent/oe-oauth/user-auth-flow.js +66 -0
  133. package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -0
  134. package/dist/agent/postgres/neon.d.ts +29 -0
  135. package/dist/agent/postgres/neon.d.ts.map +1 -0
  136. package/dist/agent/postgres/neon.js +76 -0
  137. package/dist/agent/postgres/neon.js.map +1 -0
  138. package/dist/agent/sandbox/backend-stop.d.ts +98 -0
  139. package/dist/agent/sandbox/backend-stop.d.ts.map +1 -0
  140. package/dist/agent/sandbox/backend-stop.js +188 -0
  141. package/dist/agent/sandbox/backend-stop.js.map +1 -0
  142. package/dist/agent/sandbox/created-sandbox-registry.d.ts +15 -0
  143. package/dist/agent/sandbox/created-sandbox-registry.d.ts.map +1 -0
  144. package/dist/agent/sandbox/created-sandbox-registry.js +26 -0
  145. package/dist/agent/sandbox/created-sandbox-registry.js.map +1 -0
  146. package/dist/agent/sandbox/extend-workspace-time.d.ts +31 -0
  147. package/dist/agent/sandbox/extend-workspace-time.d.ts.map +1 -0
  148. package/dist/agent/sandbox/extend-workspace-time.js +63 -0
  149. package/dist/agent/sandbox/extend-workspace-time.js.map +1 -0
  150. package/dist/agent/sandbox/vercel-lifecycle.d.ts +38 -0
  151. package/dist/agent/sandbox/vercel-lifecycle.d.ts.map +1 -0
  152. package/dist/agent/sandbox/vercel-lifecycle.js +244 -0
  153. package/dist/agent/sandbox/vercel-lifecycle.js.map +1 -0
  154. package/package.json +204 -0
  155. package/tsconfig.build.json +22 -0
  156. package/tsconfig.json +22 -0
@@ -0,0 +1,85 @@
1
+ // The Vercel AI Gateway model backend — the reference `ModelBackend`. Provider SELECTION is not pinned here:
2
+ // that is a gateway-operator concern, and `ModelRoute.providers` is only an optional per-route preference.
3
+
4
+ import { gateway, createGateway } from "ai";
5
+ import {
6
+ type ModelRoute,
7
+ type BindableContext,
8
+ type ModelBackend,
9
+ type RoutedCallOptions,
10
+ liveIdentity,
11
+ resolvePrimaryModel,
12
+ } from "@open-everywhere/core/agent/adapter-api.js";
13
+
14
+ type GatewayModel = ReturnType<typeof gateway>;
15
+
16
+ /**
17
+ * THE GATEWAY TARGET — `OE_AI_GATEWAY_URL`, read once at module load: a target that changed mid-process would
18
+ * send two calls in one turn to two providers. Unset ⇒ the plain `gateway` export, byte-identical.
19
+ */
20
+ const GATEWAY_BASE_URL = process.env.OE_AI_GATEWAY_URL?.trim();
21
+
22
+ /** The gateway provider this backend resolves model ids through — retargeted only when
23
+ * {@link GATEWAY_BASE_URL} names a different host. */
24
+ const provider = GATEWAY_BASE_URL ? createGateway({ baseURL: GATEWAY_BASE_URL }) : gateway;
25
+
26
+ /** Static defaults for one routed slot — mirrors what a plain string config said. */
27
+ export interface GatewayRoutedDefaults {
28
+ /** Default gateway model id (what unrouted channels use), e.g. "zai/glm-5.2". */
29
+ readonly id: string;
30
+ /**
31
+ * Gateway providerOptions applied when the CALL carries none — a floor, since turn calls carry their own.
32
+ * Any gateway option is forwarded verbatim; this mapping has no allow-list of keys.
33
+ */
34
+ readonly gateway?: Record<string, unknown>;
35
+ }
36
+
37
+ /** Gateway pin keys a route replaces — a pin belongs to the id it was set with. */
38
+ const PIN_KEYS = ["only", "order"] as const;
39
+
40
+ /**
41
+ * Map a route's provider hints onto one call's gateway options. Pure. A MODEL CHANGE strips the pin keys — a
42
+ * pin set for one model is broken for another — and a route's `providers` becomes `order`, never `only`.
43
+ */
44
+ export function applyGatewayOptions(
45
+ route: ModelRoute,
46
+ options: RoutedCallOptions,
47
+ ids: { readonly id: string; readonly defaultId: string },
48
+ defaults?: Record<string, unknown>,
49
+ ): RoutedCallOptions {
50
+ const base = (options.providerOptions?.gateway ?? defaults) as Record<string, unknown> | undefined;
51
+ let gatewayOpts = base;
52
+ if (ids.id !== ids.defaultId && base !== undefined) {
53
+ gatewayOpts = { ...base };
54
+ for (const k of PIN_KEYS) delete gatewayOpts[k];
55
+ }
56
+ if (route.providers !== undefined) {
57
+ gatewayOpts = { ...(gatewayOpts ?? {}), order: [...route.providers] };
58
+ delete gatewayOpts["only"];
59
+ }
60
+ if (gatewayOpts === undefined) return options;
61
+ return { ...options, providerOptions: { ...options.providerOptions, gateway: gatewayOpts } };
62
+ }
63
+
64
+ /** The gateway ModelBackend (see core ModelBackend). */
65
+ export function gatewayModelBackend(defaults?: Record<string, unknown>): ModelBackend<GatewayModel> {
66
+ return {
67
+ model: (id) => provider(id),
68
+ applyOptions: (route, options, ids) => applyGatewayOptions(route, options, ids, defaults),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * A `step.started` dynamic-model resolver. It MUST be `step.started`: eve discards a model INSTANCE at turn
74
+ * scope. It ALWAYS returns a model, never null, and `event` carries the turn id this ctx does not.
75
+ */
76
+ export function gatewayModelResolver(primary: GatewayRoutedDefaults): (event: unknown, ctx: unknown) => Promise<GatewayModel> {
77
+ const deps = { ...liveIdentity, backend: gatewayModelBackend(primary.gateway) };
78
+ return (event, ctx) => resolvePrimaryModel(ctx as BindableContext, { primary: { id: primary.id } }, deps, event);
79
+ }
80
+
81
+ // There is deliberately NO exported compaction model here. eve 0.38.3 cannot run an authored
82
+ // `compaction.model` under a dynamic agent model — it resolves the slot's reference through the
83
+ // AGENT-LEVEL `model` field and throws (OPE-670; the full chain is on `agent/agent.ts`'s
84
+ // `compaction` block). Compaction runs on the live turn model instead, which this file already
85
+ // serves through `gatewayModelResolver`.
@@ -0,0 +1,246 @@
1
+ // The `oe-oauth` master flag and provider config, env-sourced. 🔴 The client SECRET is a REF — a literal in
2
+ // the JSON is REFUSED. ⚠️ A map key must equal the credential home's ref, or the click dies late, at DCR.
3
+
4
+ /** The master arming flag env var — one string this module's readers share. */
5
+ const OE_OAUTH_ENABLED_ENV = "OE_OAUTH_ENABLED";
6
+ /** The JSON provider-config block env var. */
7
+ export const OE_OAUTH_CONFIG_ENV = "OE_OAUTH_CONFIG";
8
+ /** Convenience: a `notion` provider's resource (mcp.notion.com), without hand-writing the JSON. */
9
+ const OE_OAUTH_NOTION_RESOURCE_ENV = "OE_OAUTH_NOTION_RESOURCE";
10
+
11
+ /** The discovery + DCR + PKCE public-client dialect — the original and default mode. */
12
+ export const OE_OAUTH_MODE_DISCOVERY = "discovery-dcr-pkce";
13
+ /** The static confidential-client dialect (OPE-1086) — no discovery, no registration, HTTP Basic. */
14
+ export const OE_OAUTH_MODE_STATIC = "static-confidential";
15
+
16
+ /** How a static provider encodes its token-request body. Declared, never inferred: Notion's REST
17
+ * token endpoint takes JSON, the RFC 6749 default is form-encoded, and sending the wrong one reads
18
+ * as an authentication failure rather than as a shape error. */
19
+ export type OeOauthBodyEncoding = "json" | "form";
20
+
21
+ /** Fields every provider entry carries, whatever its mode. */
22
+ interface OeOauthProviderConfigBase {
23
+ /** The opaque connector id (the connection's providerRef, and this map's key). */
24
+ readonly ref: string;
25
+ /** Requested scopes (space-joined onto the authorization + token requests when present). */
26
+ readonly scopes?: readonly string[];
27
+ }
28
+
29
+ /** A provider whose authorization server publishes RFC 8414 metadata and an RFC 7591 registration
30
+ * endpoint — the hosted-MCP shape the adapter was built for. */
31
+ export interface OeOauthDiscoveryProviderConfig extends OeOauthProviderConfigBase {
32
+ readonly mode: typeof OE_OAUTH_MODE_DISCOVERY;
33
+ /** The hosted-MCP server URL — discovery base + RFC 8707 resource indicator. */
34
+ readonly resource: string;
35
+ /** The `client_name` sent at Dynamic Client Registration (default "Open Everywhere"). */
36
+ readonly clientName?: string;
37
+ }
38
+
39
+ /**
40
+ * A provider whose client is created BY A HUMAN in the vendor's developer portal, and authenticated
41
+ * as a CONFIDENTIAL client with a secret (OPE-1086). Nothing here is discovered at runtime.
42
+ */
43
+ export interface OeOauthStaticProviderConfig extends OeOauthProviderConfigBase {
44
+ readonly mode: typeof OE_OAUTH_MODE_STATIC;
45
+ /** Where the human is sent to consent. HTTPS only. */
46
+ readonly authorizationEndpoint: string;
47
+ /** Where the code is exchanged and the refresh happens. HTTPS only. */
48
+ readonly tokenEndpoint: string;
49
+ /** RFC 7009 revocation, when the vendor offers one. ABSENT ⇒ a purge is local-only, exactly as it
50
+ * is for a discovered server that publishes no `revocation_endpoint`. Notion documents none. */
51
+ readonly revocationEndpoint?: string;
52
+ /** The client id from the vendor's developer portal. Not a secret — it rides the authorize URL. */
53
+ readonly clientId: string;
54
+ /** The credential-store REF of the client secret. NEVER the value. */
55
+ readonly clientSecretRef: string;
56
+ /** The broker the ref resolves through. Default "db" — the encrypted credential store. */
57
+ readonly clientSecretBroker: string;
58
+ /** Extra authorize-URL query parameters the vendor requires (Notion needs `owner=user`). The
59
+ * parameters this adapter sets itself are RESERVED and cannot be overridden here. */
60
+ readonly authorizeParams?: Readonly<Record<string, string>>;
61
+ /** Extra token-request headers the vendor requires (Notion needs `Notion-Version`). `authorization`
62
+ * is RESERVED — config may not replace the Basic credential. */
63
+ readonly tokenHeaders?: Readonly<Record<string, string>>;
64
+ /** How the token-request body is encoded. */
65
+ readonly bodyEncoding: OeOauthBodyEncoding;
66
+ }
67
+
68
+ /** One provider's OAuth target, in one of the two declared modes. */
69
+ export type OeOauthProviderConfig = OeOauthDiscoveryProviderConfig | OeOauthStaticProviderConfig;
70
+
71
+ /** Narrow a provider config to the static confidential-client mode. Reads the DECLARED `mode` and
72
+ * nothing else — never the presence of a field. */
73
+ export function isStaticConfidential(cfg: OeOauthProviderConfig): cfg is OeOauthStaticProviderConfig {
74
+ return cfg.mode === OE_OAUTH_MODE_STATIC;
75
+ }
76
+
77
+ /** Truthiness for the master flag — same off-spellings as the connector flags (default OFF). */
78
+ export function oeOauthEnabled(): boolean {
79
+ const raw = process.env[OE_OAUTH_ENABLED_ENV];
80
+ if (raw === undefined) return false;
81
+ const v = raw.trim().toLowerCase();
82
+ return v !== "" && v !== "0" && v !== "false" && v !== "off" && v !== "no";
83
+ }
84
+
85
+ /** Authorize-URL parameters this adapter composes itself. A config entry that named one of these
86
+ * could point the consent at another client id or another redirect target, so the parser refuses
87
+ * them instead of letting the last writer win. */
88
+ const RESERVED_AUTHORIZE_PARAMS = new Set(["response_type", "client_id", "redirect_uri", "state", "scope", "code_challenge", "code_challenge_method"]);
89
+
90
+ function requireString(v: unknown, where: string): string {
91
+ if (typeof v !== "string" || v.trim() === "") throw new Error(`${where} must be a non-empty string.`);
92
+ return v.trim();
93
+ }
94
+
95
+ /** An endpoint must be an absolute HTTPS URL. A client secret rides these requests in an
96
+ * `Authorization` header, so `http:` would put it on the wire in the clear, and a relative value
97
+ * would resolve against whatever base the runtime happened to have. */
98
+ function requireHttpsUrl(v: unknown, where: string): string {
99
+ const raw = requireString(v, where);
100
+ let parsed: URL;
101
+ try {
102
+ parsed = new URL(raw);
103
+ } catch {
104
+ throw new Error(`${where} must be an absolute URL (got ${JSON.stringify(raw)}).`);
105
+ }
106
+ if (parsed.protocol !== "https:") {
107
+ throw new Error(`${where} must use https — a confidential client presents its secret on this request, so ${JSON.stringify(parsed.protocol)} is refused.`);
108
+ }
109
+ return raw;
110
+ }
111
+
112
+ function optionalStringMap(v: unknown, where: string): Record<string, string> | undefined {
113
+ if (v === undefined) return undefined;
114
+ if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error(`${where} must be a JSON object of string values.`);
115
+ const out: Record<string, string> = {};
116
+ for (const [k, raw] of Object.entries(v as Record<string, unknown>)) {
117
+ if (typeof raw !== "string") throw new Error(`${where}["${k}"] must be a string.`);
118
+ out[k] = raw;
119
+ }
120
+ return Object.keys(out).length > 0 ? out : undefined;
121
+ }
122
+
123
+ function parseScopes(v: unknown): readonly string[] | undefined {
124
+ const scopes = Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : undefined;
125
+ return scopes && scopes.length ? scopes : undefined;
126
+ }
127
+
128
+ /** Parse ONE `static-confidential` entry, with every refusal stated. */
129
+ function parseStaticProvider(ref: string, rec: Record<string, unknown>): OeOauthStaticProviderConfig {
130
+ const where = `${OE_OAUTH_CONFIG_ENV}["${ref}"]`;
131
+ // 🔴 A LITERAL SECRET IS REFUSED, NOT ACCEPTED. See the module header: this block is an env var.
132
+ if ("clientSecret" in rec) {
133
+ throw new Error(
134
+ `${where} carries a literal "clientSecret". The client secret must NEVER live in this config block — ` +
135
+ `store it in the credential store and name it with "clientSecretRef" (for example "db:notion:rest-client-secret").`,
136
+ );
137
+ }
138
+ const authorizeParams = optionalStringMap(rec.authorizeParams, `${where}.authorizeParams`);
139
+ for (const name of Object.keys(authorizeParams ?? {})) {
140
+ if (RESERVED_AUTHORIZE_PARAMS.has(name.toLowerCase())) {
141
+ throw new Error(`${where}.authorizeParams may not set "${name}" — the adapter composes that parameter itself.`);
142
+ }
143
+ }
144
+ const tokenHeaders = optionalStringMap(rec.tokenHeaders, `${where}.tokenHeaders`);
145
+ for (const name of Object.keys(tokenHeaders ?? {})) {
146
+ if (name.toLowerCase() === "authorization") {
147
+ throw new Error(`${where}.tokenHeaders may not set "${name}" — that header carries the confidential client's Basic credential.`);
148
+ }
149
+ }
150
+ const bodyEncoding = requireString(rec.bodyEncoding, `${where}.bodyEncoding`);
151
+ if (bodyEncoding !== "json" && bodyEncoding !== "form") {
152
+ throw new Error(`${where}.bodyEncoding must be "json" or "form" — the dialect is declared, not guessed.`);
153
+ }
154
+ const revocationEndpoint = rec.revocationEndpoint === undefined ? undefined : requireHttpsUrl(rec.revocationEndpoint, `${where}.revocationEndpoint`);
155
+ const scopes = parseScopes(rec.scopes);
156
+ return {
157
+ mode: OE_OAUTH_MODE_STATIC,
158
+ ref,
159
+ authorizationEndpoint: requireHttpsUrl(rec.authorizationEndpoint, `${where}.authorizationEndpoint`),
160
+ tokenEndpoint: requireHttpsUrl(rec.tokenEndpoint, `${where}.tokenEndpoint`),
161
+ ...(revocationEndpoint ? { revocationEndpoint } : {}),
162
+ clientId: requireString(rec.clientId, `${where}.clientId`),
163
+ clientSecretRef: requireString(rec.clientSecretRef, `${where}.clientSecretRef`),
164
+ clientSecretBroker: rec.clientSecretBroker === undefined ? "db" : requireString(rec.clientSecretBroker, `${where}.clientSecretBroker`),
165
+ ...(authorizeParams ? { authorizeParams } : {}),
166
+ ...(tokenHeaders ? { tokenHeaders } : {}),
167
+ bodyEncoding,
168
+ };
169
+ }
170
+
171
+ /** Parse ONE `discovery-dcr-pkce` entry. */
172
+ function parseDiscoveryProvider(ref: string, rec: Record<string, unknown>): OeOauthDiscoveryProviderConfig {
173
+ const where = `${OE_OAUTH_CONFIG_ENV}["${ref}"]`;
174
+ const resource = typeof rec.resource === "string" ? rec.resource.trim() : "";
175
+ if (!resource) throw new Error(`${where} is missing a string "resource".`);
176
+ const scopes = parseScopes(rec.scopes);
177
+ return {
178
+ mode: OE_OAUTH_MODE_DISCOVERY,
179
+ ref,
180
+ resource,
181
+ ...(scopes ? { scopes } : {}),
182
+ ...(typeof rec.clientName === "string" && rec.clientName.trim() ? { clientName: rec.clientName.trim() } : {}),
183
+ };
184
+ }
185
+
186
+ function parseJsonConfig(raw: string): Map<string, OeOauthProviderConfig> {
187
+ const out = new Map<string, OeOauthProviderConfig>();
188
+ let obj: unknown;
189
+ try {
190
+ obj = JSON.parse(raw);
191
+ } catch {
192
+ throw new Error(`${OE_OAUTH_CONFIG_ENV} is not valid JSON — expected {"<ref>":{"resource":"..."}}.`);
193
+ }
194
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
195
+ throw new Error(`${OE_OAUTH_CONFIG_ENV} must be a JSON object mapping provider ref → one provider entry.`);
196
+ }
197
+ for (const [ref, v] of Object.entries(obj as Record<string, unknown>)) {
198
+ if (typeof v !== "object" || v === null) continue;
199
+ const rec = v as Record<string, unknown>;
200
+ // The MODE decides how the rest is read. An absent `mode` is the discovery dialect, which is
201
+ // what every entry authored before OPE-1086 means. An unknown one is refused rather than
202
+ // defaulted: silently reading a mistyped mode as the public-client flow would drop the client
203
+ // secret and the Basic authentication without saying so.
204
+ const mode = rec.mode === undefined ? OE_OAUTH_MODE_DISCOVERY : rec.mode;
205
+ if (mode === OE_OAUTH_MODE_STATIC) {
206
+ out.set(ref, parseStaticProvider(ref, rec));
207
+ continue;
208
+ }
209
+ if (mode !== OE_OAUTH_MODE_DISCOVERY) {
210
+ throw new Error(
211
+ `${OE_OAUTH_CONFIG_ENV}["${ref}"].mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}" or "${OE_OAUTH_MODE_STATIC}".`,
212
+ );
213
+ }
214
+ const parsed = parseDiscoveryProvider(ref, rec);
215
+ // A discovery entry that also carries confidential-client fields is a MIS-DECLARED entry, not a
216
+ // static one. Refusing it here is the whole "declared, not sniffed" rule in one place: the
217
+ // adapter will not promote an entry to the confidential mode because it looks like one.
218
+ for (const field of ["clientId", "clientSecretRef", "clientSecret", "tokenEndpoint", "authorizationEndpoint"]) {
219
+ if (field in rec) {
220
+ throw new Error(
221
+ `${OE_OAUTH_CONFIG_ENV}["${ref}"] carries "${field}" but declares no mode, so it reads as "${OE_OAUTH_MODE_DISCOVERY}" ` +
222
+ `(discovery + dynamic registration + PKCE), which ignores that field. Add "mode": "${OE_OAUTH_MODE_STATIC}" if that is what you meant.`,
223
+ );
224
+ }
225
+ }
226
+ out.set(ref, parsed);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Load the provider config map from env, or `undefined` when no provider is configured (the
233
+ * "config-absent" path — combined with {@link oeOauthEnabled}, both must be truthy for the own-rolled
234
+ * OAuth layer to register). Explicit OE_OAUTH_CONFIG entries win over the notion shortcut.
235
+ */
236
+ export function loadOeOauthConfig(): Map<string, OeOauthProviderConfig> | undefined {
237
+ const providers = new Map<string, OeOauthProviderConfig>();
238
+
239
+ const notionResource = process.env[OE_OAUTH_NOTION_RESOURCE_ENV]?.trim();
240
+ if (notionResource) providers.set("notion", { mode: OE_OAUTH_MODE_DISCOVERY, ref: "notion", resource: notionResource });
241
+
242
+ const rawJson = process.env[OE_OAUTH_CONFIG_ENV]?.trim();
243
+ if (rawJson) for (const [ref, cfg] of parseJsonConfig(rawJson)) providers.set(ref, cfg);
244
+
245
+ return providers.size > 0 ? providers : undefined;
246
+ }
@@ -0,0 +1,77 @@
1
+ // Typed errors for the own-rolled OAuth adapter. Each carries a stable `code`, so classification is
2
+ // DUCK-TYPED across processes. SECURITY: no error here carries a token, a code, or a PKCE verifier.
3
+
4
+ /** The subject an error is about, as a log-safe label (never an opaque token). */
5
+ export function subjectLabel(subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }): string {
6
+ return subject.type === "app" ? "app" : `user:${subject.id}`;
7
+ }
8
+
9
+ /** The human has NEVER authorized this (provider, subject) — no token is stored. The connector plane
10
+ * turns this into a fresh consent link. */
11
+ export class OeOauthNeedsConsentError extends Error {
12
+ readonly code = "oe_oauth_needs_consent";
13
+ constructor(providerRef: string, subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }) {
14
+ super(`connection "${providerRef}": ${subjectLabel(subject)} has not authorized this account — user authorization required.`);
15
+ this.name = "OeOauthNeedsConsentError";
16
+ }
17
+ }
18
+
19
+ /** A previously-working authorization is dead (refresh token rejected / rotated away) — the human
20
+ * must RE-authorize. Distinct from needs-consent so the audit trail can tell a first connect from a
21
+ * lapsed one, but both classify as "user authorization required". */
22
+ export class OeOauthNeedsReconsentError extends Error {
23
+ readonly code = "oe_oauth_needs_reconsent";
24
+ constructor(providerRef: string, subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }, why: string) {
25
+ super(`connection "${providerRef}": ${subjectLabel(subject)} must re-authorize — ${why}.`);
26
+ this.name = "OeOauthNeedsReconsentError";
27
+ }
28
+ }
29
+
30
+ /** The cross-process single-flight LOSER timed out (OPE-91): another instance holds the refresh claim
31
+ * and did not release a rotated token within this caller's wait budget. TRANSIENT and RETRYABLE — a
32
+ * fresh request may win the claim or find the rotated token — so it is DELIBERATELY not classified as
33
+ * user-auth-required (the human need do nothing; nothing is re-consented). Carries no secret material,
34
+ * only the provider ref + subject label. */
35
+ export class OeOauthRefreshContendedError extends Error {
36
+ readonly code = "oe_oauth_refresh_contended";
37
+ constructor(providerRef: string, subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }) {
38
+ super(`connection "${providerRef}": ${subjectLabel(subject)} token refresh is in progress on another instance and did not complete in time — retry.`);
39
+ this.name = "OeOauthRefreshContendedError";
40
+ }
41
+ }
42
+
43
+ /** A generic upstream OAuth failure (token endpoint / discovery / DCR returned an error). `oauthError`
44
+ * is the RFC 6749 `error` code when the body carried one (e.g. "invalid_grant", "invalid_client") —
45
+ * a fixed-vocabulary string, never a secret. */
46
+ export class OeOauthError extends Error {
47
+ readonly code = "oe_oauth_error";
48
+ readonly oauthError?: string;
49
+ readonly httpStatus?: number;
50
+ constructor(message: string, opts: { readonly oauthError?: string; readonly httpStatus?: number } = {}) {
51
+ super(message);
52
+ this.name = "OeOauthError";
53
+ if (opts.oauthError) this.oauthError = opts.oauthError;
54
+ if (opts.httpStatus !== undefined) this.httpStatus = opts.httpStatus;
55
+ }
56
+ }
57
+
58
+ /** True iff `e` is an upstream OAuth failure whose `error` code is `invalid_grant` (the refresh token
59
+ * was rejected — consumed by a rotation, revoked, or expired). Duck-typed for cross-process safety. */
60
+ export function isInvalidGrant(e: unknown): boolean {
61
+ if (!(e instanceof Error)) return false;
62
+ const oauthError = (e as { oauthError?: unknown }).oauthError;
63
+ return typeof oauthError === "string" && oauthError.toLowerCase() === "invalid_grant";
64
+ }
65
+
66
+ /** The connector-plane classifier (used by UserAuthFlowProvider.isUserAuthRequired). Duck-typed on
67
+ * the stable `code` strings so a cross-process instance still matches. */
68
+ export function isUserAuthRequiredError(e: unknown): boolean {
69
+ if (!(e instanceof Error)) return false;
70
+ const code = (e as { code?: unknown }).code;
71
+ return (
72
+ (typeof code === "string" && (code === "oe_oauth_needs_consent" || code === "oe_oauth_needs_reconsent")) ||
73
+ e.name === "OeOauthNeedsConsentError" ||
74
+ e.name === "OeOauthNeedsReconsentError" ||
75
+ /user[_ ]?authorization[_ ]?required/i.test(e.message)
76
+ );
77
+ }