@moxxy/plugin-oauth 0.0.33

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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/openai-device-flow.d.ts +34 -0
  3. package/dist/adapters/openai-device-flow.d.ts.map +1 -0
  4. package/dist/adapters/openai-device-flow.js +136 -0
  5. package/dist/adapters/openai-device-flow.js.map +1 -0
  6. package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
  7. package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
  8. package/dist/adapters/rfc8628-device-flow.js +54 -0
  9. package/dist/adapters/rfc8628-device-flow.js.map +1 -0
  10. package/dist/credential-lock.d.ts +60 -0
  11. package/dist/credential-lock.d.ts.map +1 -0
  12. package/dist/credential-lock.js +149 -0
  13. package/dist/credential-lock.js.map +1 -0
  14. package/dist/ensure-fresh.d.ts +63 -0
  15. package/dist/ensure-fresh.d.ts.map +1 -0
  16. package/dist/ensure-fresh.js +122 -0
  17. package/dist/ensure-fresh.js.map +1 -0
  18. package/dist/flow.d.ts +6 -0
  19. package/dist/flow.d.ts.map +1 -0
  20. package/dist/flow.js +5 -0
  21. package/dist/flow.js.map +1 -0
  22. package/dist/index.d.ts +43 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +82 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/oauth/browser-flow.d.ts +23 -0
  27. package/dist/oauth/browser-flow.d.ts.map +1 -0
  28. package/dist/oauth/browser-flow.js +82 -0
  29. package/dist/oauth/browser-flow.js.map +1 -0
  30. package/dist/oauth/callback-server.d.ts +10 -0
  31. package/dist/oauth/callback-server.d.ts.map +1 -0
  32. package/dist/oauth/callback-server.js +158 -0
  33. package/dist/oauth/callback-server.js.map +1 -0
  34. package/dist/oauth/device-flow-shared.d.ts +39 -0
  35. package/dist/oauth/device-flow-shared.d.ts.map +1 -0
  36. package/dist/oauth/device-flow-shared.js +101 -0
  37. package/dist/oauth/device-flow-shared.js.map +1 -0
  38. package/dist/oauth/device-flow.d.ts +29 -0
  39. package/dist/oauth/device-flow.d.ts.map +1 -0
  40. package/dist/oauth/device-flow.js +74 -0
  41. package/dist/oauth/device-flow.js.map +1 -0
  42. package/dist/oauth/poll-until.d.ts +41 -0
  43. package/dist/oauth/poll-until.d.ts.map +1 -0
  44. package/dist/oauth/poll-until.js +70 -0
  45. package/dist/oauth/poll-until.js.map +1 -0
  46. package/dist/oauth/token-exchange.d.ts +28 -0
  47. package/dist/oauth/token-exchange.d.ts.map +1 -0
  48. package/dist/oauth/token-exchange.js +142 -0
  49. package/dist/oauth/token-exchange.js.map +1 -0
  50. package/dist/oauth/types.d.ts +90 -0
  51. package/dist/oauth/types.d.ts.map +1 -0
  52. package/dist/oauth/types.js +2 -0
  53. package/dist/oauth/types.js.map +1 -0
  54. package/dist/open-browser.d.ts +32 -0
  55. package/dist/open-browser.d.ts.map +1 -0
  56. package/dist/open-browser.js +83 -0
  57. package/dist/open-browser.js.map +1 -0
  58. package/dist/pkce.d.ts +20 -0
  59. package/dist/pkce.d.ts.map +1 -0
  60. package/dist/pkce.js +34 -0
  61. package/dist/pkce.js.map +1 -0
  62. package/dist/profile.d.ts +129 -0
  63. package/dist/profile.d.ts.map +1 -0
  64. package/dist/profile.js +11 -0
  65. package/dist/profile.js.map +1 -0
  66. package/dist/run-login.d.ts +9 -0
  67. package/dist/run-login.d.ts.map +1 -0
  68. package/dist/run-login.js +93 -0
  69. package/dist/run-login.js.map +1 -0
  70. package/dist/storage.d.ts +46 -0
  71. package/dist/storage.d.ts.map +1 -0
  72. package/dist/storage.js +167 -0
  73. package/dist/storage.js.map +1 -0
  74. package/dist/tools.d.ts +8 -0
  75. package/dist/tools.d.ts.map +1 -0
  76. package/dist/tools.js +283 -0
  77. package/dist/tools.js.map +1 -0
  78. package/package.json +73 -0
  79. package/skills/google-oauth.md +313 -0
  80. package/skills/oauth-flow.md +132 -0
  81. package/src/adapters/openai-device-flow.test.ts +191 -0
  82. package/src/adapters/openai-device-flow.ts +179 -0
  83. package/src/adapters/rfc8628-device-flow.ts +75 -0
  84. package/src/credential-lock.ts +183 -0
  85. package/src/discovery.test.ts +35 -0
  86. package/src/ensure-fresh.test.ts +279 -0
  87. package/src/ensure-fresh.ts +178 -0
  88. package/src/flow.ts +9 -0
  89. package/src/index.ts +153 -0
  90. package/src/oauth/browser-flow.ts +96 -0
  91. package/src/oauth/callback-server.test.ts +90 -0
  92. package/src/oauth/callback-server.ts +208 -0
  93. package/src/oauth/device-flow-shared.test.ts +142 -0
  94. package/src/oauth/device-flow-shared.ts +129 -0
  95. package/src/oauth/device-flow.test.ts +102 -0
  96. package/src/oauth/device-flow.ts +82 -0
  97. package/src/oauth/poll-until.test.ts +63 -0
  98. package/src/oauth/poll-until.ts +100 -0
  99. package/src/oauth/token-exchange.test.ts +224 -0
  100. package/src/oauth/token-exchange.ts +169 -0
  101. package/src/oauth/types.ts +92 -0
  102. package/src/oauth.test.ts +185 -0
  103. package/src/open-browser-spawn.test.ts +69 -0
  104. package/src/open-browser.test.ts +30 -0
  105. package/src/open-browser.ts +84 -0
  106. package/src/pkce.ts +37 -0
  107. package/src/profile.test.ts +331 -0
  108. package/src/profile.ts +135 -0
  109. package/src/run-login.ts +123 -0
  110. package/src/storage.test.ts +191 -0
  111. package/src/storage.ts +208 -0
  112. package/src/tools.test.ts +239 -0
  113. package/src/tools.ts +332 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Device-authorization adapter for OpenAI's non-standard flow (used by
3
+ * Codex/ChatGPT, opencode, codex-rs). Not RFC 8628 — speaks a JSON dialect:
4
+ *
5
+ * 1. POST `${issuer}/api/accounts/deviceauth/usercode` JSON `{client_id}`
6
+ * → `{device_auth_id, user_code, interval}`.
7
+ * 2. Surface `user_code` + a product-specific verification URI
8
+ * (`verificationUri`) to the user.
9
+ * 3. Poll `${issuer}/api/accounts/deviceauth/token` JSON
10
+ * `{device_auth_id, user_code}`. 403/404 ⇒ keep polling. 200 returns
11
+ * `{authorization_code, code_verifier}`.
12
+ * 4. Exchange that `authorization_code` + `code_verifier` for real tokens
13
+ * via the standard `/oauth/token` endpoint with
14
+ * `redirect_uri=${issuer}/deviceauth/callback` (not an actual redirect
15
+ * target — just a registered value the server expects to see).
16
+ *
17
+ * Reusable for any OpenAI-issued client; product-specific bits are the
18
+ * verification URI and the `client_id`.
19
+ */
20
+
21
+ import { classifyHttpStatus, MoxxyError } from '@moxxy/sdk';
22
+ import type { TokenSet } from '../oauth/types.js';
23
+ import { exchangeCodeForToken } from '../oauth/token-exchange.js';
24
+ import type {
25
+ DeviceFlowAdapter,
26
+ DeviceFlowInit,
27
+ DeviceFlowStartArgs,
28
+ } from '../profile.js';
29
+ import type { PollOutcome, PollState } from '../oauth/poll-until.js';
30
+
31
+ export interface OpenaiDeviceFlowOpts {
32
+ /** Auth issuer base URL, e.g. `https://auth.openai.com`. */
33
+ readonly issuer: string;
34
+ /** Standard OAuth token endpoint — `parseTokenResponse` consumes its reply. */
35
+ readonly tokenUrl: string;
36
+ /**
37
+ * URL the user opens to enter the device code. Product-specific —
38
+ * Codex uses `${issuer}/codex/device`. The init endpoint does NOT
39
+ * return one, so the caller supplies it.
40
+ */
41
+ readonly verificationUri: string;
42
+ }
43
+
44
+ interface OpenaiDeviceState {
45
+ readonly deviceAuthId: string;
46
+ readonly userCode: string;
47
+ readonly clientId: string;
48
+ }
49
+
50
+ export function openaiDeviceFlow(opts: OpenaiDeviceFlowOpts): DeviceFlowAdapter {
51
+ const initUrl = `${opts.issuer}/api/accounts/deviceauth/usercode`;
52
+ const pollUrl = `${opts.issuer}/api/accounts/deviceauth/token`;
53
+ const exchangeRedirectUri = `${opts.issuer}/deviceauth/callback`;
54
+
55
+ return {
56
+ async start(args: DeviceFlowStartArgs): Promise<DeviceFlowInit> {
57
+ const res = await fetch(initUrl, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ client_id: args.clientId }),
61
+ ...(args.signal ? { signal: args.signal } : {}),
62
+ });
63
+ if (!res.ok) {
64
+ const text = await res.text().catch(() => '');
65
+ throw (
66
+ classifyHttpStatus(res.status, { url: initUrl, body: text || res.statusText }) ??
67
+ new MoxxyError({
68
+ code: 'AUTH_INVALID',
69
+ message: `device auth init failed: ${res.status} ${text || res.statusText}`,
70
+ context: { status: res.status, url: initUrl },
71
+ })
72
+ );
73
+ }
74
+ const data = (await res.json().catch(() => {
75
+ throw new MoxxyError({
76
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
77
+ message: 'device-auth init returned a non-JSON success body',
78
+ context: { url: initUrl },
79
+ });
80
+ })) as {
81
+ device_auth_id?: unknown;
82
+ user_code?: unknown;
83
+ interval?: string | number;
84
+ expires_in?: string | number;
85
+ };
86
+ // Required-field validation: a 200 missing device_auth_id/user_code would
87
+ // otherwise produce a poll body of `{ device_auth_id: undefined }` that
88
+ // 403s forever until timeout. Reject clearly instead.
89
+ if (typeof data.device_auth_id !== 'string' || typeof data.user_code !== 'string') {
90
+ throw new MoxxyError({
91
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
92
+ message: 'device-auth init response missing device_auth_id or user_code',
93
+ context: { url: initUrl },
94
+ });
95
+ }
96
+ // Coerce-then-validate: a malformed server value (e.g. interval:"" or a
97
+ // non-numeric string) parses to NaN, which would poison the poll timing
98
+ // (`setTimeout(_, NaN)` busy-loops; a NaN deadline aborts the flow). Only
99
+ // accept a finite number; otherwise fall back to the RFC-style defaults.
100
+ const rawInterval =
101
+ typeof data.interval === 'string' ? parseInt(data.interval, 10) : data.interval;
102
+ const intervalSec = Math.max(Number.isFinite(rawInterval) ? (rawInterval as number) : 5, 1);
103
+ const rawExpiresIn =
104
+ typeof data.expires_in === 'string' ? parseInt(data.expires_in, 10) : data.expires_in;
105
+ const expiresInSec = Number.isFinite(rawExpiresIn) ? (rawExpiresIn as number) : 600;
106
+ return {
107
+ userCode: data.user_code,
108
+ verificationUri: opts.verificationUri,
109
+ intervalMs: intervalSec * 1000,
110
+ expiresInMs: expiresInSec * 1000,
111
+ providerData: {
112
+ deviceAuthId: data.device_auth_id,
113
+ userCode: data.user_code,
114
+ clientId: args.clientId,
115
+ } satisfies OpenaiDeviceState,
116
+ };
117
+ },
118
+
119
+ async poll(init: DeviceFlowInit, state: PollState): Promise<PollOutcome<TokenSet>> {
120
+ const { deviceAuthId, userCode, clientId } = init.providerData as OpenaiDeviceState;
121
+ const res = await fetch(pollUrl, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
125
+ ...(state.signal ? { signal: state.signal } : {}),
126
+ });
127
+ if (res.ok) {
128
+ const data = (await res.json().catch(() => {
129
+ throw new MoxxyError({
130
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
131
+ message: 'device-auth poll returned a non-JSON success body',
132
+ context: { url: pollUrl },
133
+ });
134
+ })) as {
135
+ authorization_code?: unknown;
136
+ code_verifier?: unknown;
137
+ };
138
+ // Required-field validation: without it a 200 omitting these would call
139
+ // exchangeCodeForToken with undefined, which URLSearchParams coerces to
140
+ // the literal 'undefined' — a wasted exchange that fails opaquely.
141
+ if (typeof data.authorization_code !== 'string' || typeof data.code_verifier !== 'string') {
142
+ throw new MoxxyError({
143
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
144
+ message: 'device-auth poll response missing authorization_code or code_verifier',
145
+ context: { url: pollUrl },
146
+ });
147
+ }
148
+ // Two-step: poll returns a server-side authorization_code we
149
+ // exchange via the standard token endpoint. The redirect_uri
150
+ // here is the issuer's device callback — a registered value,
151
+ // not a URI we listen on. Route through the shared exchange
152
+ // helper so this dialect can't drift from browser-flow.
153
+ return {
154
+ done: await exchangeCodeForToken({
155
+ tokenUrl: opts.tokenUrl,
156
+ code: data.authorization_code,
157
+ redirectUri: exchangeRedirectUri,
158
+ clientId,
159
+ codeVerifier: data.code_verifier,
160
+ ...(state.signal ? { signal: state.signal } : {}),
161
+ }),
162
+ };
163
+ }
164
+ // OpenAI's "still waiting" signal — 403 or 404 with no further detail.
165
+ if (res.status === 403 || res.status === 404) {
166
+ return { pending: true };
167
+ }
168
+ const text = await res.text().catch(() => '');
169
+ throw (
170
+ classifyHttpStatus(res.status, { url: pollUrl, body: text || res.statusText }) ??
171
+ new MoxxyError({
172
+ code: 'AUTH_INVALID',
173
+ message: `Device auth poll failed: ${res.status} ${text || res.statusText}`,
174
+ context: { status: res.status, url: pollUrl },
175
+ })
176
+ );
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Standards-compliant device-authorization adapter per RFC 8628.
3
+ *
4
+ * Phases:
5
+ * 1. POST `client_id` + `scope` to `deviceUrl` (form-encoded).
6
+ * 2. Surface `user_code` + `verification_uri` to the user.
7
+ * 3. Poll `tokenUrl` every `interval` with
8
+ * `grant_type=urn:ietf:params:oauth:grant-type:device_code` + `device_code`.
9
+ * 4. Handle `authorization_pending` / `slow_down` / fatal codes per spec.
10
+ *
11
+ * The device-authorization request + poll-response classification are shared
12
+ * with the legacy `runDeviceCodeFlow` via `oauth/device-flow-shared`.
13
+ */
14
+
15
+ import {
16
+ classifyDeviceTokenResponse,
17
+ requestDeviceAuthorization,
18
+ } from '../oauth/device-flow-shared.js';
19
+ import type { TokenSet } from '../oauth/types.js';
20
+ import type {
21
+ DeviceFlowAdapter,
22
+ DeviceFlowInit,
23
+ DeviceFlowStartArgs,
24
+ } from '../profile.js';
25
+ import type { PollOutcome, PollState } from '../oauth/poll-until.js';
26
+
27
+ export interface Rfc8628AdapterOpts {
28
+ readonly deviceUrl: string;
29
+ readonly tokenUrl: string;
30
+ }
31
+
32
+ interface Rfc8628State {
33
+ readonly deviceCode: string;
34
+ }
35
+
36
+ export function rfc8628DeviceFlow(opts: Rfc8628AdapterOpts): DeviceFlowAdapter {
37
+ return {
38
+ async start(args: DeviceFlowStartArgs): Promise<DeviceFlowInit> {
39
+ const auth = await requestDeviceAuthorization({
40
+ deviceUrl: opts.deviceUrl,
41
+ clientId: args.clientId,
42
+ scopes: args.scopes,
43
+ ...(args.signal ? { signal: args.signal } : {}),
44
+ });
45
+ return {
46
+ userCode: auth.userCode,
47
+ verificationUri: auth.verificationUri,
48
+ ...(auth.verificationUriComplete
49
+ ? { verificationUriComplete: auth.verificationUriComplete }
50
+ : {}),
51
+ intervalMs: auth.intervalSec * 1000,
52
+ expiresInMs: auth.expiresInSec * 1000,
53
+ providerData: { deviceCode: auth.deviceCode } satisfies Rfc8628State,
54
+ };
55
+ },
56
+
57
+ async poll(init: DeviceFlowInit, state: PollState): Promise<PollOutcome<TokenSet>> {
58
+ const { deviceCode } = init.providerData as Rfc8628State;
59
+ const body = new URLSearchParams();
60
+ body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
61
+ body.set('device_code', deviceCode);
62
+ const res = await fetch(opts.tokenUrl, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/x-www-form-urlencoded',
66
+ Accept: 'application/json',
67
+ },
68
+ body: body.toString(),
69
+ ...(state.signal ? { signal: state.signal } : {}),
70
+ });
71
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
72
+ return classifyDeviceTokenResponse(res, json, state);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Per-credential refresh serialization.
3
+ *
4
+ * OAuth providers with ROTATING single-use refresh tokens (Anthropic's Claude
5
+ * subscription, OpenAI's Codex/ChatGPT plan) invalidate the previous
6
+ * refresh_token on every refresh. Two concurrent refreshes with the same
7
+ * stored token therefore race: one wins, the other burns a now-dead token and
8
+ * logs the user out. `withCredentialLock` serializes "refresh + persist"
9
+ * critical sections at two levels:
10
+ *
11
+ * 1. In-process: a per-key `Mutex` (promise chain) so concurrent consumers
12
+ * in one process (e.g. the chat provider and the whisper-stt transcriber
13
+ * sharing the codex credential) coalesce into a single refresh — the
14
+ * followers re-read the vault under the lock and reuse the winner's
15
+ * rotated tokens.
16
+ * 2. Cross-process: a best-effort O_EXCL lockfile under
17
+ * `<moxxy home>/locks/` with stale-lock takeover, so a TUI and a desktop
18
+ * runner refreshing the same credential queue up instead of racing.
19
+ *
20
+ * The file lock is deliberately best-effort: if the lock directory is
21
+ * unusable, or a live-but-slow holder keeps it past `waitMs`, we proceed
22
+ * WITHOUT the lock rather than deadlocking auth — the vault's read-merge-write
23
+ * persistence and the callers' invalid_grant re-read-retry recovery keep the
24
+ * losing side recoverable even then.
25
+ */
26
+
27
+ import { mkdir, open, rename, rm, stat } from 'node:fs/promises';
28
+ import { randomBytes } from 'node:crypto';
29
+ import { dirname, join } from 'node:path';
30
+ import { MoxxyError, createMutex, type Mutex } from '@moxxy/sdk';
31
+ import { moxxyPath } from '@moxxy/sdk/server';
32
+
33
+ export interface CredentialLockOptions {
34
+ /** Directory holding the lock files. Default `<moxxy home>/locks`. */
35
+ readonly dir?: string;
36
+ /**
37
+ * Take over a lock file whose mtime is older than this (holder crashed
38
+ * without releasing). Default 60s — comfortably above a worst-case retried
39
+ * token refresh.
40
+ */
41
+ readonly staleMs?: number;
42
+ /** Poll interval while waiting on a held lock. Default 150ms. */
43
+ readonly pollMs?: number;
44
+ /**
45
+ * Max time to wait for the file lock before proceeding WITHOUT it
46
+ * (best effort — never deadlock auth on a wedged lock). Default 30s.
47
+ */
48
+ readonly waitMs?: number;
49
+ }
50
+
51
+ const DEFAULT_STALE_MS = 60_000;
52
+ const DEFAULT_POLL_MS = 150;
53
+ const DEFAULT_WAIT_MS = 30_000;
54
+
55
+ /**
56
+ * In-process locks, keyed by sanitized credential key. Module-level so every
57
+ * consumer of this package instance (providers, stt, tools) shares them.
58
+ */
59
+ const inProcessLocks = new Map<string, Mutex>();
60
+
61
+ /**
62
+ * Run `fn` while holding the per-credential lock (in-process mutex +
63
+ * best-effort cross-process lockfile). Callers should RE-READ their stored
64
+ * credential inside `fn` — a queued waiter usually finds the winner's
65
+ * freshly rotated tokens and can skip its own refresh entirely.
66
+ */
67
+ export async function withCredentialLock<T>(
68
+ key: string,
69
+ fn: () => Promise<T>,
70
+ opts: CredentialLockOptions = {},
71
+ ): Promise<T> {
72
+ const safe = sanitizeKey(key);
73
+ let mutex = inProcessLocks.get(safe);
74
+ if (!mutex) {
75
+ mutex = createMutex();
76
+ inProcessLocks.set(safe, mutex);
77
+ }
78
+ return mutex.run(async () => {
79
+ const release = await acquireFileLock(
80
+ join(opts.dir ?? moxxyPath('locks'), `${safe}.lock`),
81
+ opts.staleMs ?? DEFAULT_STALE_MS,
82
+ opts.pollMs ?? DEFAULT_POLL_MS,
83
+ opts.waitMs ?? DEFAULT_WAIT_MS,
84
+ );
85
+ try {
86
+ return await fn();
87
+ } finally {
88
+ await release();
89
+ }
90
+ });
91
+ }
92
+
93
+ /**
94
+ * True for deterministic credential rejections from a token endpoint, as
95
+ * opposed to transient network/5xx failures. Covers the AUTH_* codes (401/403
96
+ * and refresh-specific failures) plus PROVIDER_BAD_REQUEST — `invalid_grant`
97
+ * is canonically an HTTP 400 (RFC 6749 §5.2), which `classifyHttpStatus` maps
98
+ * there. Used to decide whether a failed refresh is worth retrying with a
99
+ * fresher refresh_token re-read from the vault (another process may have
100
+ * rotated ours away).
101
+ */
102
+ export function isAuthRejection(err: unknown): boolean {
103
+ return (
104
+ err instanceof MoxxyError &&
105
+ (err.code === 'AUTH_INVALID' ||
106
+ err.code === 'AUTH_DENIED' ||
107
+ err.code === 'AUTH_EXPIRED' ||
108
+ err.code === 'PROVIDER_BAD_REQUEST')
109
+ );
110
+ }
111
+
112
+ function sanitizeKey(key: string): string {
113
+ return key.replace(/[^a-zA-Z0-9._-]+/g, '_');
114
+ }
115
+
116
+ /**
117
+ * O_EXCL ('wx') lockfile acquisition with stale takeover. Returns a release
118
+ * function. Never throws: an unusable lock dir degrades to running unlocked.
119
+ */
120
+ async function acquireFileLock(
121
+ lockPath: string,
122
+ staleMs: number,
123
+ pollMs: number,
124
+ waitMs: number,
125
+ ): Promise<() => Promise<void>> {
126
+ const deadline = Date.now() + waitMs;
127
+ try {
128
+ await mkdir(dirname(lockPath), { recursive: true });
129
+ } catch {
130
+ return async () => {};
131
+ }
132
+ for (;;) {
133
+ try {
134
+ const handle = await open(lockPath, 'wx');
135
+ try {
136
+ await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`, 'utf8');
137
+ } finally {
138
+ await handle.close().catch(() => {});
139
+ }
140
+ return async () => {
141
+ await rm(lockPath, { force: true }).catch(() => {});
142
+ };
143
+ } catch (err) {
144
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
145
+ // Read-only FS, permissions, … — degrade to unlocked rather than
146
+ // blocking the user's auth on infrastructure trouble.
147
+ return async () => {};
148
+ }
149
+ }
150
+ // Lock held — take over if the holder looks dead, else wait and re-try.
151
+ // Takeover is made atomic via rename() to dodge the rm()+open() TOCTOU: a
152
+ // naive rm() lets two processes both judge the same lock stale and both
153
+ // delete it, and one could unlink a holder's freshly-recreated lock. POSIX
154
+ // rename is atomic and single-winner — only one racer's rename of the SAME
155
+ // inode succeeds; the loser's rename fails (ENOENT/the file moved) and it
156
+ // spins to re-acquire instead of clobbering the winner. We rename to a
157
+ // unique temp then rm that temp, so the original path is freed for the
158
+ // winner's `open('wx')` on the next spin. The file lock stays best-effort
159
+ // (see the module header): any failure degrades to the unlocked-but-
160
+ // recoverable path guarded by the in-process mutex + invalid_grant retry.
161
+ try {
162
+ const st = await stat(lockPath);
163
+ if (Date.now() - st.mtimeMs > staleMs) {
164
+ const tmp = `${lockPath}.stale-${process.pid}-${randomBytes(6).toString('hex')}`;
165
+ try {
166
+ await rename(lockPath, tmp);
167
+ } catch {
168
+ continue; // lost the takeover race (someone moved/recreated it) — respin
169
+ }
170
+ await rm(tmp, { force: true }).catch(() => {});
171
+ continue;
172
+ }
173
+ } catch {
174
+ continue; // released between open() and stat() — grab it on the next spin
175
+ }
176
+ if (Date.now() >= deadline) return async () => {};
177
+ await sleep(pollMs);
178
+ }
179
+ }
180
+
181
+ function sleep(ms: number): Promise<void> {
182
+ return new Promise((resolve) => setTimeout(resolve, ms));
183
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ServiceRegistry } from '@moxxy/sdk';
3
+ import { oauthPlugin } from './index.js';
4
+
5
+ /**
6
+ * The discovery-loadable default export resolves the vault from the inter-plugin
7
+ * service registry in onInit instead of a `build*({ vault })` closure. (The tool
8
+ * behaviour with an injected vault is covered by tools.test via buildOauthPlugin.)
9
+ */
10
+ describe('oauthPlugin (discovery-loadable)', () => {
11
+ it('exposes the oauth tools and an onInit hook', () => {
12
+ expect(oauthPlugin.tools?.map((t) => t.name).sort()).toEqual([
13
+ 'oauth_authorize',
14
+ 'oauth_clear_token',
15
+ 'oauth_get_token',
16
+ ]);
17
+ expect(typeof oauthPlugin.hooks?.onInit).toBe('function');
18
+ });
19
+
20
+ it('onInit resolves the "vault" service from the registry', () => {
21
+ const fakeVault = { tag: 'vault-store' };
22
+ const require = vi.fn((name: string) => {
23
+ if (name !== 'vault') throw new Error(`unexpected service: ${name}`);
24
+ return fakeVault;
25
+ });
26
+ const services = {
27
+ require,
28
+ get: () => fakeVault,
29
+ has: () => true,
30
+ register: () => {},
31
+ } as unknown as ServiceRegistry;
32
+ oauthPlugin.hooks!.onInit!({ services } as never);
33
+ expect(require).toHaveBeenCalledWith('vault');
34
+ });
35
+ });