@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,107 @@
1
+ import type { EncryptedKvVault } from "@open-everywhere/core/agent/adapter-api.js";
2
+ import { type OeOauthProviderConfig } from "./config.js";
3
+ import { type OAuthFetch } from "./oauth-client.js";
4
+ /** A subject a token is minted for — the service identity, or a specific user principal. */
5
+ export type OeOauthSubject = {
6
+ readonly type: "user";
7
+ readonly id: string;
8
+ } | {
9
+ readonly type: "app";
10
+ };
11
+ /** What one {@link OeOauthTokenVault.sweepExpiredPending} run looked at. The four counts
12
+ * RECONCILE: `scanned` === `deleted` + `live` + `unreadable` + (rows another caller removed
13
+ * between the listing and the delete). */
14
+ interface PendingSweepReport {
15
+ /** Pending rows the enumeration returned. */
16
+ readonly scanned: number;
17
+ /** Rows destroyed — past `expiresAt` + the retention window. */
18
+ readonly deleted: number;
19
+ /** Rows KEPT because they may still complete (inside the window, or inside the grace). */
20
+ readonly live: number;
21
+ /** Rows KEPT because their record did not read, so their expiry could not be established. */
22
+ readonly unreadable: number;
23
+ }
24
+ export declare const CLAIM_LEASE_TTL_MS = 30000;
25
+ /** Injectable seams — every live default is used byte-for-byte in production. */
26
+ export interface OeOauthVaultDeps {
27
+ readonly vault?: EncryptedKvVault;
28
+ readonly fetch?: OAuthFetch;
29
+ readonly now?: () => number;
30
+ readonly newState?: () => string;
31
+ readonly newPkce?: () => {
32
+ readonly verifier: string;
33
+ readonly challenge: string;
34
+ };
35
+ /** The OE-owned callback redirect URI. Default: OE_PUBLIC_BASE_URL + the callback route path. */
36
+ readonly redirectUri?: () => string | undefined;
37
+ readonly pendingTtlMs?: number;
38
+ /** How long past `expiresAt` an unusable pending row is kept. Default {@link DEFAULT_PENDING_RETENTION_MS}. */
39
+ readonly pendingRetentionMs?: number;
40
+ readonly expirySkewMs?: number;
41
+ /** Mints the random, non-secret cross-process claim id. Default: `randomUUID`. */
42
+ readonly newClaimId?: () => string;
43
+ /** Backoff wait between a loser's poll iterations. Default: real `setTimeout`. Tests inject a
44
+ * clock-advancing (timeout scenario) or microtask-yielding (concurrency scenario) stub. */
45
+ readonly sleep?: (ms: number) => Promise<void>;
46
+ /**
47
+ * Read a `static-confidential` provider's CLIENT SECRET by ref through its broker — the one credential path
48
+ * every other stored secret takes. Read PER TOKEN CALL, so a rotated secret takes effect at once.
49
+ */
50
+ readonly resolveSecret?: (broker: string | undefined, ref: string) => Promise<string>;
51
+ }
52
+ declare function subjectKey(subject: OeOauthSubject): string;
53
+ /**
54
+ * Construct a token vault over the configured providers. One instance is built at registration and
55
+ * shared, so the in-process single-flight lock (core withLock) coordinates all callers in the process.
56
+ */
57
+ export declare function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProviderConfig>, deps?: OeOauthVaultDeps): {
58
+ subjectKey: typeof subjectKey;
59
+ /** {@link forget} — purge ONE provider's token record for a subject. */
60
+ forget: (providerRef: string, subject: OeOauthSubject) => Promise<boolean>;
61
+ /** {@link sweepExpiredPending} — destroy the expired pending-authorization rows. */
62
+ sweepExpiredPending: () => Promise<PendingSweepReport>;
63
+ /**
64
+ * Purge EVERY configured provider's token record for `subject` — only the vault knows the full set. A
65
+ * provider whose purge THROWS does not stop the others, and it rethrows only if every one failed.
66
+ */
67
+ forgetAll(subject: OeOauthSubject): Promise<number>;
68
+ /**
69
+ * (OPE-93) Cheap EXISTENCE probe: is there a stored token record for (provider, subject) RIGHT NOW?
70
+ * A local vault read only — never a mint, never a refresh. Existence is the right signal even if the
71
+ * access token is expired: the real mint path ({@link accessTokenFor}) refreshes it. A corrupt record
72
+ * counts as absent (mirrors the mint's readJson semantics — fail toward re-consent, never crash).
73
+ */
74
+ hasToken(providerRef: string, subject: OeOauthSubject): Promise<boolean>;
75
+ /**
76
+ * Start an interactive authorization: ensure the client, mint PKCE and an opaque state, persist the pending
77
+ * row, and return the URL. It ALSO sweeps the expired pending rows, LAST and best-effort.
78
+ */
79
+ startAuthorization(providerRef: string, subject: OeOauthSubject, options: {
80
+ readonly expiresInMs?: number;
81
+ readonly consent?: unknown;
82
+ }): Promise<{
83
+ url: string;
84
+ }>;
85
+ /**
86
+ * Complete the callback: look up the pending record by `state`, exchange the `code`, store the
87
+ * tokens, and consume the pending row (single-use, verifier scrubbed). Returns the provider ref,
88
+ * the SUBJECT the row was bound for and whether that bind REPLACED an existing row — the three
89
+ * facts the callback route's `connector.credential_bound` audit row carries (OPE-1937). NEVER a
90
+ * token: the caller writes an audit row from this, and no token material may reach it.
91
+ * Throws on unknown/expired/consumed state or an exchange failure — the callback route renders it.
92
+ */
93
+ completeAuthorization(state: string, code: string): Promise<{
94
+ providerRef: string;
95
+ subject: OeOauthSubject;
96
+ rebound: boolean;
97
+ consent?: unknown;
98
+ }>;
99
+ /**
100
+ * Read-or-refresh the access token. The fast path returns a valid token with no lock; otherwise the two-layer
101
+ * single-flight runs — an in-process mutex, then a cross-process row CLAIM that elects ONE refresher.
102
+ */
103
+ accessTokenFor(providerRef: string, subject: OeOauthSubject): Promise<string>;
104
+ };
105
+ export type OeOauthTokenVault = ReturnType<typeof makeOeOauthTokenVault>;
106
+ export {};
107
+ //# sourceMappingURL=token-vault.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-vault.d.ts","sourceRoot":"","sources":["../../../agent/oe-oauth/token-vault.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,498 @@
1
+ // The own-rolled OAuth token vault — DCR client, pending-authorization and token records over core's
2
+ // encrypted KV. Refresh is single-flight in-process AND cross-process, because the provider ROTATES tokens.
3
+ import { encryptedKvVault, getSecretVia, OE_OAUTH_CALLBACK_PATH } from "@open-everywhere/core/agent/adapter-api.js";
4
+ import { isStaticConfidential, OE_OAUTH_MODE_STATIC } from "./config.js";
5
+ import { discoverEndpoints, registerClient, buildAuthorizationUrl, buildStaticAuthorizationUrl, exchangeCode, exchangeCodeConfidential, refreshToken, refreshTokenConfidential, revokeToken, revokeTokenConfidential, } from "./oauth-client.js";
6
+ import { randomUUID } from "node:crypto";
7
+ import { makePkce, makeState } from "./pkce.js";
8
+ import { OeOauthNeedsConsentError, OeOauthNeedsReconsentError, OeOauthRefreshContendedError, isInvalidGrant } from "./errors.js";
9
+ function isStaticClient(rec) {
10
+ return rec.mode === OE_OAUTH_MODE_STATIC;
11
+ }
12
+ const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000; // 15 min consent window
13
+ const DEFAULT_EXPIRY_SKEW_MS = 60 * 1000; // refresh when within 60s of expiry
14
+ // How long an expired pending row is KEPT before the sweep destroys it. Not a security window — completion
15
+ // already refuses a row past `expiresAt` — it buys the true error, "authorization state expired".
16
+ const DEFAULT_PENDING_RETENTION_MS = 60 * 60 * 1000;
17
+ // A claim is a LEASE: the winner stamps it on the row with an atomic CAS and releases it. A claim older than
18
+ // the lease TTL counts as abandoned and may be stolen, so a crashed winner never wedges the row.
19
+ export const CLAIM_LEASE_TTL_MS = 30_000;
20
+ // A LOSER never calls the token endpoint — it waits for the winner's rotated token and adopts it, then fails
21
+ // closed with a retryable error. DELIBERATELY shorter than the lease, so it never steals a valid claim.
22
+ const LOSER_WAIT_BUDGET_MS = 15_000;
23
+ // Poll cadence while a loser waits for the winner's rotated token to land.
24
+ const LOSER_POLL_MS = 250;
25
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ function subjectKey(subject) {
27
+ return subject.type === "app" ? "app" : `user:${subject.id}`;
28
+ }
29
+ function defaultRedirectUri() {
30
+ const base = process.env.OE_PUBLIC_BASE_URL?.trim().replace(/\/+$/, "");
31
+ if (!base)
32
+ return undefined;
33
+ const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
34
+ return `${withScheme}${OE_OAUTH_CALLBACK_PATH}`;
35
+ }
36
+ /**
37
+ * Construct a token vault over the configured providers. One instance is built at registration and
38
+ * shared, so the in-process single-flight lock (core withLock) coordinates all callers in the process.
39
+ */
40
+ export function makeOeOauthTokenVault(providers, deps = {}) {
41
+ const vault = deps.vault ?? encryptedKvVault();
42
+ const doFetch = deps.fetch ?? ((input, init) => fetch(input, init));
43
+ const now = deps.now ?? Date.now;
44
+ const newState = deps.newState ?? makeState;
45
+ const newPkce = deps.newPkce ?? makePkce;
46
+ const redirectUriOf = deps.redirectUri ?? defaultRedirectUri;
47
+ const pendingTtl = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
48
+ const pendingRetention = deps.pendingRetentionMs ?? DEFAULT_PENDING_RETENTION_MS;
49
+ const skew = deps.expirySkewMs ?? DEFAULT_EXPIRY_SKEW_MS;
50
+ const newClaimId = deps.newClaimId ?? (() => randomUUID());
51
+ const sleep = deps.sleep ?? realSleep;
52
+ const resolveSecret = deps.resolveSecret ?? ((broker, ref) => getSecretVia(broker, ref));
53
+ const dcrRef = (providerRef) => `oe-oauth:dcr:${providerRef}`;
54
+ const pendRef = (state) => `oe-oauth:pend:${state}`;
55
+ const tokRef = (providerRef, subject) => `oe-oauth:tok:${providerRef}:${subjectKey(subject)}`;
56
+ function providerConfig(providerRef) {
57
+ const cfg = providers.get(providerRef);
58
+ if (!cfg)
59
+ throw new Error(`connection "${providerRef}": no oe-oauth provider config (set OE_OAUTH_CONFIG / OE_OAUTH_NOTION_RESOURCE).`);
60
+ return cfg;
61
+ }
62
+ async function readJson(ref) {
63
+ const raw = await vault.read(ref);
64
+ if (raw === null)
65
+ return null;
66
+ try {
67
+ return JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return null; // a corrupt record is treated as absent — fail closed to re-consent, never crash a mint
71
+ }
72
+ }
73
+ /** True when {@link ensureDcrClient} should attempt a BACKFILL (OPE-1037) — re-run discovery and
74
+ * merge what it learns onto an EXISTING record, never re-register. Only a field discovery alone
75
+ * can supply belongs here; `clientId`/`registrationEndpoint` never change by this path. */
76
+ function dcrRecordNeedsBackfill(rec) {
77
+ return rec.revocationEndpoint === undefined;
78
+ }
79
+ /** Read-or-register the provider's DCR client, under the DCR-ref lock (no double registration).
80
+ * Also BACKFILLS (OPE-1037): a record missing a field the adapter now understands is re-checked
81
+ * against live discovery and merged, never re-registered — see the module header. */
82
+ async function ensureDcrClient(providerRef) {
83
+ const existing = await readJson(dcrRef(providerRef));
84
+ if (existing && !dcrRecordNeedsBackfill(existing))
85
+ return existing;
86
+ const cfg = providerConfig(providerRef);
87
+ // Unreachable through {@link ensureClient}, which routes a static provider away from this
88
+ // function entirely. Stated as a refusal rather than a cast so a future caller that reaches it
89
+ // directly gets an error instead of a discovery request built from an absent `resource`.
90
+ if (isStaticConfidential(cfg))
91
+ throw new Error(`connection "${providerRef}": declares the "${OE_OAUTH_MODE_STATIC}" mode, which registers no dynamic client.`);
92
+ return vault.withLock(dcrRef(providerRef), async () => {
93
+ const again = await readJson(dcrRef(providerRef));
94
+ if (again && !dcrRecordNeedsBackfill(again))
95
+ return again; // another caller already completed it
96
+ if (again) {
97
+ // BACKFILL, not registration: re-discover and merge the missing field(s) onto the record
98
+ // that is already there. Best-effort — a discovery failure or a server that genuinely
99
+ // publishes none must never block the mint the caller actually asked for.
100
+ try {
101
+ const endpoints = await discoverEndpoints(cfg.resource, doFetch);
102
+ if (endpoints.revocationEndpoint === undefined)
103
+ return again; // this provider cannot be told
104
+ const backfilled = { ...again, revocationEndpoint: endpoints.revocationEndpoint };
105
+ await vault.write(dcrRef(providerRef), JSON.stringify(backfilled), { connector: providerRef });
106
+ return backfilled;
107
+ }
108
+ catch (e) {
109
+ console.warn(`[oe-oauth] DCR backfill discovery failed for "${providerRef}" (using the frozen record): ${e instanceof Error ? e.message : String(e)}`);
110
+ return again;
111
+ }
112
+ }
113
+ const redirectUri = redirectUriOf();
114
+ if (!redirectUri) {
115
+ throw new Error(`connection "${providerRef}": OE_PUBLIC_BASE_URL is unset — cannot register a DCR client without a redirect_uri.`);
116
+ }
117
+ const endpoints = await discoverEndpoints(cfg.resource, doFetch);
118
+ const { clientId } = await registerClient(endpoints.registrationEndpoint, { clientName: cfg.clientName ?? "Open Everywhere", redirectUri, ...(cfg.scopes ? { scopes: cfg.scopes } : {}) }, doFetch);
119
+ const record = {
120
+ clientId,
121
+ authorizationEndpoint: endpoints.authorizationEndpoint,
122
+ tokenEndpoint: endpoints.tokenEndpoint,
123
+ registrationEndpoint: endpoints.registrationEndpoint,
124
+ ...(endpoints.revocationEndpoint ? { revocationEndpoint: endpoints.revocationEndpoint } : {}),
125
+ resource: cfg.resource,
126
+ ...(cfg.scopes ? { scopes: cfg.scopes } : {}),
127
+ registeredAt: now(),
128
+ };
129
+ await vault.write(dcrRef(providerRef), JSON.stringify(record), { connector: providerRef });
130
+ return record;
131
+ });
132
+ }
133
+ /** The client of a `static-confidential` provider, straight from config. NO network call, NO vault
134
+ * read, NO vault write, NO lock — see the module header for why there is nothing to persist. */
135
+ function staticClient(cfg) {
136
+ return {
137
+ mode: OE_OAUTH_MODE_STATIC,
138
+ clientId: cfg.clientId,
139
+ authorizationEndpoint: cfg.authorizationEndpoint,
140
+ tokenEndpoint: cfg.tokenEndpoint,
141
+ ...(cfg.revocationEndpoint ? { revocationEndpoint: cfg.revocationEndpoint } : {}),
142
+ ...(cfg.scopes ? { scopes: cfg.scopes } : {}),
143
+ ...(cfg.authorizeParams ? { authorizeParams: cfg.authorizeParams } : {}),
144
+ };
145
+ }
146
+ /**
147
+ * The provider's client, in whichever dialect its config DECLARES. A static provider never reaches
148
+ * `ensureDcrClient`, so it never discovers, registers or writes a DCR row — the two share no network path.
149
+ */
150
+ async function ensureClient(providerRef) {
151
+ const cfg = providers.get(providerRef);
152
+ return cfg !== undefined && isStaticConfidential(cfg) ? staticClient(cfg) : ensureDcrClient(providerRef);
153
+ }
154
+ /**
155
+ * Resolve a static provider's confidential client, client secret included, for ONE token call.
156
+ *
157
+ * 🔴 THE ERROR NAMES THE REF, NEVER THE VALUE. A missing secret is the ordinary un-provisioned
158
+ * state (the operator has not run `oe secret set` yet), and it has to say which ref to fill —
159
+ * which is a config string, not secret material.
160
+ */
161
+ async function confidentialClientFor(cfg) {
162
+ const secret = await resolveSecret(cfg.clientSecretBroker, cfg.clientSecretRef);
163
+ if (typeof secret !== "string" || secret === "") {
164
+ throw new Error(`connection "${cfg.ref}": the OAuth client secret at ref "${cfg.clientSecretRef}" (broker "${cfg.clientSecretBroker}") has no stored value — ` +
165
+ `store it with \`oe secret set ${cfg.clientSecretRef}\` before this connection can mint a token.`);
166
+ }
167
+ return {
168
+ clientId: cfg.clientId,
169
+ clientSecret: secret,
170
+ bodyEncoding: cfg.bodyEncoding,
171
+ ...(cfg.tokenHeaders ? { headers: cfg.tokenHeaders } : {}),
172
+ };
173
+ }
174
+ /**
175
+ * Exchange the callback's `code` against ONE pending row, in the dialect that row was minted in.
176
+ * 🔴 The row's stamp and the live config must AGREE: a disagreement is REFUSED rather than resolved.
177
+ */
178
+ async function exchangeForPending(pending, code) {
179
+ // `providers.get`, not providerConfig: the discovery path never read the config here before this
180
+ // mode existed, and it must not start failing on a config-absent ref now. The drift check below
181
+ // therefore runs only when there IS a config to compare against.
182
+ const cfg = providers.get(pending.providerRef);
183
+ const startedStatic = pending.mode === OE_OAUTH_MODE_STATIC;
184
+ if (cfg !== undefined && isStaticConfidential(cfg) !== startedStatic) {
185
+ throw new Error(`connection "${pending.providerRef}": this authorization started as ${startedStatic ? "a static confidential client" : "a public PKCE client"}, ` +
186
+ `but the provider config no longer declares that mode — start again.`);
187
+ }
188
+ if (startedStatic) {
189
+ if (cfg === undefined || !isStaticConfidential(cfg)) {
190
+ throw new Error(`connection "${pending.providerRef}": no static confidential provider config, so the client that started this authorization cannot be resolved — start again.`);
191
+ }
192
+ return exchangeCodeConfidential(pending.tokenEndpoint, { code, redirectUri: pending.redirectUri }, await confidentialClientFor(cfg), doFetch);
193
+ }
194
+ if (!pending.resource)
195
+ throw new Error(`connection "${pending.providerRef}": this authorization record carries no resource indicator — start again.`);
196
+ return exchangeCode(pending.tokenEndpoint, { code, verifier: pending.pkceVerifier, clientId: pending.clientId, redirectUri: pending.redirectUri, resource: pending.resource }, doFetch);
197
+ }
198
+ function isExpired(rec) {
199
+ return rec.expiresAt !== undefined && now() >= rec.expiresAt - skew;
200
+ }
201
+ async function storeToken(providerRef, subject, tok, priorRefresh) {
202
+ const refresh = tok.refresh_token ?? priorRefresh; // keep the prior refresh if the server didn't rotate
203
+ const record = {
204
+ accessToken: tok.access_token,
205
+ ...(refresh ? { refreshToken: refresh } : {}),
206
+ ...(typeof tok.expires_in === "number" ? { expiresAt: now() + tok.expires_in * 1000 } : {}),
207
+ ...(tok.scope ? { scopes: tok.scope.split(/\s+/).filter(Boolean) } : {}),
208
+ obtainedAt: now(),
209
+ };
210
+ await vault.write(tokRef(providerRef, subject), JSON.stringify(record), { connector: providerRef });
211
+ return record;
212
+ }
213
+ /**
214
+ * PURGE one (provider, subject) record: vendor revocation FIRST, then the at-rest row. The ORDER makes a
215
+ * racing cross-process refresh benign; where a vendor cannot cascade, the access token lives out its TTL.
216
+ */
217
+ async function forget(providerRef, subject) {
218
+ const ref = tokRef(providerRef, subject);
219
+ return vault.withLock(ref, async () => {
220
+ const rec = await readJson(ref);
221
+ if (rec) {
222
+ // The client, in whichever dialect this provider declares. A STATIC provider's client comes
223
+ // from config — which is a read, not a registration, so the "never registers" rule above
224
+ // holds on both paths. `providers.get` rather than `providerConfig`: a purge must still
225
+ // destroy the row for a provider whose config an operator has already removed.
226
+ const cfg = providers.get(providerRef);
227
+ const client = cfg && isStaticConfidential(cfg) ? staticClient(cfg) : await readJson(dcrRef(providerRef));
228
+ // Prefer the REFRESH token: revoking it kills the whole grant family (RFC 7009 §2.1),
229
+ // whereas revoking an access token kills only that one.
230
+ const token = rec.refreshToken ?? rec.accessToken;
231
+ if (client?.revocationEndpoint && token) {
232
+ try {
233
+ const hint = rec.refreshToken ? "refresh_token" : "access_token";
234
+ if (cfg && isStaticConfidential(cfg)) {
235
+ await revokeTokenConfidential(client.revocationEndpoint, { token, tokenTypeHint: hint }, await confidentialClientFor(cfg), doFetch);
236
+ }
237
+ else {
238
+ await revokeToken(client.revocationEndpoint, { token, clientId: client.clientId, tokenTypeHint: hint }, doFetch);
239
+ }
240
+ }
241
+ catch (e) {
242
+ // A vendor failure NEVER blocks the local delete — leaving a secret at rest with us
243
+ // because someone else's server is down is the worse of the two outcomes. Message only:
244
+ // the client's errors are built never to carry token material.
245
+ console.warn(`[oe-oauth] revocation at the provider failed for "${providerRef}" (purging locally anyway): ${e instanceof Error ? e.message : String(e)}`);
246
+ }
247
+ }
248
+ }
249
+ // Runs even when there was no record to read (a corrupt/undecryptable envelope reads as null —
250
+ // readJson's fail-toward-reconsent semantics — and that row must still be destroyed).
251
+ return vault.delete(ref);
252
+ });
253
+ }
254
+ /**
255
+ * DESTROY the pending rows past `expiresAt` plus the retention grace: a row keyed by the opaque `state` can
256
+ * never be named again, yet an unconsumed one still holds its PKCE verifier. A row that will not READ is KEPT.
257
+ */
258
+ async function sweepExpiredPending() {
259
+ const refs = await vault.listRefs("oe-oauth:pend:");
260
+ let deleted = 0;
261
+ let live = 0;
262
+ let unreadable = 0;
263
+ for (const ref of refs) {
264
+ // The read is guarded PER ROW. `readJson` swallows a JSON parse failure but NOT a decrypt
265
+ // failure: `vault.read` THROWS on an undecryptable envelope (a tampered blob, or an AES key
266
+ // rotated out from under the row). Unguarded, ONE such row would abort the whole sweep — and
267
+ // because the mint calls this best-effort, the abort would be swallowed and the sweep would
268
+ // silently do nothing from then on. A row we cannot read is the case this loop must survive,
269
+ // not the case that stops it.
270
+ let rec;
271
+ try {
272
+ rec = await readJson(ref);
273
+ }
274
+ catch {
275
+ unreadable++;
276
+ continue;
277
+ }
278
+ if (!rec || typeof rec.expiresAt !== "number" || !Number.isFinite(rec.expiresAt)) {
279
+ unreadable++;
280
+ continue;
281
+ }
282
+ if (now() <= rec.expiresAt + pendingRetention) {
283
+ live++;
284
+ continue;
285
+ }
286
+ if (await vault.delete(ref))
287
+ deleted++;
288
+ }
289
+ return { scanned: refs.length, deleted, live, unreadable };
290
+ }
291
+ return {
292
+ subjectKey,
293
+ /** {@link forget} — purge ONE provider's token record for a subject. */
294
+ forget,
295
+ /** {@link sweepExpiredPending} — destroy the expired pending-authorization rows. */
296
+ sweepExpiredPending,
297
+ /**
298
+ * Purge EVERY configured provider's token record for `subject` — only the vault knows the full set. A
299
+ * provider whose purge THROWS does not stop the others, and it rethrows only if every one failed.
300
+ */
301
+ async forgetAll(subject) {
302
+ let purged = 0;
303
+ let failures = 0;
304
+ let lastError;
305
+ for (const providerRef of providers.keys()) {
306
+ try {
307
+ if (await forget(providerRef, subject))
308
+ purged++;
309
+ }
310
+ catch (e) {
311
+ failures++;
312
+ lastError = e;
313
+ }
314
+ }
315
+ if (failures > 0 && purged === 0 && failures === providers.size)
316
+ throw lastError;
317
+ return purged;
318
+ },
319
+ /**
320
+ * (OPE-93) Cheap EXISTENCE probe: is there a stored token record for (provider, subject) RIGHT NOW?
321
+ * A local vault read only — never a mint, never a refresh. Existence is the right signal even if the
322
+ * access token is expired: the real mint path ({@link accessTokenFor}) refreshes it. A corrupt record
323
+ * counts as absent (mirrors the mint's readJson semantics — fail toward re-consent, never crash).
324
+ */
325
+ async hasToken(providerRef, subject) {
326
+ return (await readJson(tokRef(providerRef, subject))) !== null;
327
+ },
328
+ /**
329
+ * Start an interactive authorization: ensure the client, mint PKCE and an opaque state, persist the pending
330
+ * row, and return the URL. It ALSO sweeps the expired pending rows, LAST and best-effort.
331
+ */
332
+ async startAuthorization(providerRef, subject, options) {
333
+ const client = await ensureClient(providerRef);
334
+ const redirectUri = redirectUriOf();
335
+ if (!redirectUri)
336
+ throw new Error(`connection "${providerRef}": OE_PUBLIC_BASE_URL is unset — cannot build a redirect_uri.`);
337
+ const state = newState();
338
+ // A static confidential provider does NOT mint a PKCE pair. The mint is skipped rather than
339
+ // minted-and-discarded, so nothing writes a verifier this flow will never present.
340
+ const pkce = isStaticClient(client) ? { verifier: "", challenge: "" } : newPkce();
341
+ const pending = {
342
+ providerRef,
343
+ subject,
344
+ pkceVerifier: pkce.verifier,
345
+ tokenEndpoint: client.tokenEndpoint,
346
+ clientId: client.clientId,
347
+ ...(isStaticClient(client) ? { mode: OE_OAUTH_MODE_STATIC } : { resource: client.resource }),
348
+ redirectUri,
349
+ ...(client.scopes ? { scopes: client.scopes } : {}),
350
+ createdAt: now(),
351
+ expiresAt: now() + (options.expiresInMs ?? pendingTtl),
352
+ ...(options.consent ? { consent: options.consent } : {}),
353
+ };
354
+ await vault.write(pendRef(state), JSON.stringify(pending), { connector: providerRef });
355
+ const url = isStaticClient(client)
356
+ ? buildStaticAuthorizationUrl({
357
+ authorizationEndpoint: client.authorizationEndpoint,
358
+ clientId: client.clientId,
359
+ redirectUri,
360
+ state,
361
+ ...(client.scopes ? { scopes: client.scopes } : {}),
362
+ ...(client.authorizeParams ? { extraParams: client.authorizeParams } : {}),
363
+ })
364
+ : buildAuthorizationUrl({
365
+ authorizationEndpoint: client.authorizationEndpoint,
366
+ clientId: client.clientId,
367
+ redirectUri,
368
+ state,
369
+ codeChallenge: pkce.challenge,
370
+ resource: client.resource,
371
+ ...(client.scopes ? { scopes: client.scopes } : {}),
372
+ });
373
+ try {
374
+ await sweepExpiredPending();
375
+ }
376
+ catch (e) {
377
+ console.warn(`[oe-oauth] the expired-pending sweep failed (the authorization URL is unaffected): ${e instanceof Error ? e.message : String(e)}`);
378
+ }
379
+ return { url };
380
+ },
381
+ /**
382
+ * Complete the callback: look up the pending record by `state`, exchange the `code`, store the
383
+ * tokens, and consume the pending row (single-use, verifier scrubbed). Returns the provider ref,
384
+ * the SUBJECT the row was bound for and whether that bind REPLACED an existing row — the three
385
+ * facts the callback route's `connector.credential_bound` audit row carries (OPE-1937). NEVER a
386
+ * token: the caller writes an audit row from this, and no token material may reach it.
387
+ * Throws on unknown/expired/consumed state or an exchange failure — the callback route renders it.
388
+ */
389
+ async completeAuthorization(state, code) {
390
+ const pending = await readJson(pendRef(state));
391
+ if (!pending)
392
+ throw new Error("unknown or expired authorization state");
393
+ if (pending.consumed)
394
+ throw new Error("authorization state already used");
395
+ if (now() > pending.expiresAt)
396
+ throw new Error("authorization state expired");
397
+ const tok = await exchangeForPending(pending, code);
398
+ // 🔴 READ BEFORE THE WRITE. `storeToken` writes the row unconditionally (last-write-wins), so the
399
+ // only moment the REBIND is observable is here, before it. A second admin replacing the app row
400
+ // every routine spends is the case this flag exists to make visible.
401
+ // 🔴 AND IT IS A READ-BEFORE-WRITE WITH NO CROSS-PROCESS LOCK, so say what it proves. This method
402
+ // takes no lock at all, and `withLock` is a per-key IN-PROCESS mutex — two callbacks landing on
403
+ // separate instances for one subject can both read `null` and both report `false`, though one of
404
+ // them did replace the other. A SERIAL second consent, which is the case the row exists for,
405
+ // always reports `true`. Closing the window needs a compare-and-set write in the store, not here.
406
+ const rebound = (await readJson(tokRef(pending.providerRef, pending.subject))) !== null;
407
+ await storeToken(pending.providerRef, pending.subject, tok);
408
+ // Consume single-use + scrub the verifier (defeats replay; the verifier is spent).
409
+ // (OPE-1619) SCRUB THE CARRIAGE HERE TOO, for the same reason and at the same moment. It was
410
+ // spent the instant it was handed back below, and it holds a room id, a thread id, a
411
+ // principal id and a paused-session id — data with no remaining purpose. Leaving it until
412
+ // `sweepExpiredPending` collects the row would keep one person's conversation addresses at
413
+ // rest for the whole retention window past their last use.
414
+ await vault.write(pendRef(state), JSON.stringify({ ...pending, consumed: true, pkceVerifier: "", consent: undefined }), { connector: pending.providerRef });
415
+ // (OPE-1619) Hand the carriage back verbatim. The three refusals above run FIRST, so a consent
416
+ // that arrives on an unknown, spent or EXPIRED state returns no carriage at all — there is
417
+ // nothing for the return hop to resume from, which is where "past the TTL, a fresh ask" is
418
+ // enforced at its root rather than by a second check downstream.
419
+ return { providerRef: pending.providerRef, subject: pending.subject, rebound, ...(pending.consent ? { consent: pending.consent } : {}) };
420
+ },
421
+ /**
422
+ * Read-or-refresh the access token. The fast path returns a valid token with no lock; otherwise the two-layer
423
+ * single-flight runs — an in-process mutex, then a cross-process row CLAIM that elects ONE refresher.
424
+ */
425
+ async accessTokenFor(providerRef, subject) {
426
+ const ref = tokRef(providerRef, subject);
427
+ const fast = await readJson(ref);
428
+ if (!fast)
429
+ throw new OeOauthNeedsConsentError(providerRef, subject);
430
+ if (!isExpired(fast))
431
+ return fast.accessToken;
432
+ return vault.withLock(ref, async () => {
433
+ const rec = await readJson(ref);
434
+ if (!rec)
435
+ throw new OeOauthNeedsConsentError(providerRef, subject);
436
+ if (!isExpired(rec))
437
+ return rec.accessToken; // an in-process winner already refreshed
438
+ if (!rec.refreshToken)
439
+ throw new OeOauthNeedsReconsentError(providerRef, subject, "no refresh token stored");
440
+ const presentedRefresh = rec.refreshToken;
441
+ // Layer 2 — CROSS-PROCESS single-flight. The in-process mutex above serialises THIS process;
442
+ // the atomic row-claim serialises across processes/instances (advisory locks are unusable over
443
+ // the pooled, session-less PgClient — see the kv-vault module doc). Exactly one claimant refreshes.
444
+ const claimId = newClaimId();
445
+ const won = await vault.claim(ref, claimId, now(), CLAIM_LEASE_TTL_MS);
446
+ if (won) {
447
+ try {
448
+ // `providers.get`, not providerConfig — see {@link ensureClient}: a discovery refresh
449
+ // must still resolve from a complete DCR record when the config entry is absent.
450
+ const cfg = providers.get(providerRef);
451
+ let tok;
452
+ try {
453
+ // The dialect decides the refresh call and nothing else about this block. A static
454
+ // provider takes no discovery hop and no DCR read on its way here.
455
+ if (cfg !== undefined && isStaticConfidential(cfg)) {
456
+ tok = await refreshTokenConfidential(cfg.tokenEndpoint, { refreshToken: presentedRefresh, ...(cfg.scopes ? { scopes: cfg.scopes } : {}) }, await confidentialClientFor(cfg), doFetch);
457
+ }
458
+ else {
459
+ const client = await ensureDcrClient(providerRef);
460
+ tok = await refreshToken(client.tokenEndpoint, { refreshToken: presentedRefresh, clientId: client.clientId, resource: client.resource, ...(client.scopes ? { scopes: client.scopes } : {}) }, doFetch);
461
+ }
462
+ }
463
+ catch (e) {
464
+ if (isInvalidGrant(e)) {
465
+ // Last-resort net: another process rotated in the sliver before we claimed (or a stale
466
+ // lease let two refresh). If the stored refresh token advanced and the access token is
467
+ // fresh, adopt it instead of bricking the grant.
468
+ const rec2 = await readJson(ref);
469
+ if (rec2 && !isExpired(rec2) && rec2.refreshToken !== rec.refreshToken)
470
+ return rec2.accessToken;
471
+ throw new OeOauthNeedsReconsentError(providerRef, subject, "refresh token rejected (invalid_grant)");
472
+ }
473
+ throw e;
474
+ }
475
+ const stored = await storeToken(providerRef, subject, tok, rec.refreshToken);
476
+ return stored.accessToken;
477
+ }
478
+ finally {
479
+ await vault.releaseClaim(ref, claimId);
480
+ }
481
+ }
482
+ // Lost the claim — a live winner (another instance) is refreshing. Do NOT touch the token
483
+ // endpoint (presenting the consumed refresh token would trip reuse detection). Wait for the
484
+ // winner's rotated token to land and adopt it; bound the wait and fail closed on timeout.
485
+ const deadline = now() + LOSER_WAIT_BUDGET_MS;
486
+ for (;;) {
487
+ const rec2 = await readJson(ref);
488
+ if (rec2 && !isExpired(rec2))
489
+ return rec2.accessToken; // adopted the winner's fresh token
490
+ if (now() >= deadline)
491
+ throw new OeOauthRefreshContendedError(providerRef, subject);
492
+ await sleep(LOSER_POLL_MS);
493
+ }
494
+ });
495
+ },
496
+ };
497
+ }
498
+ //# sourceMappingURL=token-vault.js.map