@vymalo/opencode-repo-auth 0.14.1

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.
@@ -0,0 +1,331 @@
1
+ import { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, LOG_LEVEL_PRIORITY } from "@vymalo/opencode-auth-core/lib";
2
+ import { hasOAuth2Conflict, parseRepoAuthOptions } from "./config.js";
3
+ import { resolveOriginRemote, resolveRepoRoot } from "./git.js";
4
+ import { RepoAuthPlugin } from "./plugin.js";
5
+ const PLUGIN_SERVICE_NAME = "opencode-repo-auth-plugin";
6
+ /**
7
+ * Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" |
8
+ * "WARN" | "ERROR"`) to the plugin's internal `LogLevel`. Unknown / missing
9
+ * values fall through to `undefined` so the caller applies its own default —
10
+ * we never throw on an OpenCode-supplied value because the host owns
11
+ * validation of its own field. Host `DEBUG` unlocks the `trace` tier (there is
12
+ * no separate host `TRACE` level), surfacing the `repo_auth_*` trace events.
13
+ */
14
+ function fromOpenCodeLogLevel(value) {
15
+ if (typeof value !== "string") {
16
+ return undefined;
17
+ }
18
+ switch (value.toUpperCase()) {
19
+ case "DEBUG": return "trace";
20
+ case "INFO": return "info";
21
+ case "WARN": return "warn";
22
+ case "ERROR": return "error";
23
+ default: return undefined;
24
+ }
25
+ }
26
+ function asRecord(value) {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
28
+ return undefined;
29
+ }
30
+ return value;
31
+ }
32
+ function createOpenCodeLogger(client, getMinLevel) {
33
+ const fallback = createJsonConsoleLogger("debug");
34
+ const consoleAll = /^(1|true|yes|on)$/i.test(process.env.VYMALO_PLUGIN_CONSOLE_LOG ?? "");
35
+ const write = (level, event, fields) => {
36
+ if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
37
+ return;
38
+ }
39
+ if (consoleAll || level === "warn" || level === "error") {
40
+ fallback[level](event, fields);
41
+ }
42
+ const hostLevel = level === "trace" ? "debug" : level;
43
+ void client.app.log({ body: {
44
+ service: PLUGIN_SERVICE_NAME,
45
+ level: hostLevel,
46
+ message: event,
47
+ extra: fields
48
+ } }).catch(() => {
49
+ // Best-effort forwarding; console logger is the reliable fallback.
50
+ });
51
+ };
52
+ return {
53
+ trace(event, fields) {
54
+ write("trace", event, fields);
55
+ },
56
+ debug(event, fields) {
57
+ write("debug", event, fields);
58
+ },
59
+ info(event, fields) {
60
+ write("info", event, fields);
61
+ },
62
+ warn(event, fields) {
63
+ write("warn", event, fields);
64
+ },
65
+ error(event, fields) {
66
+ write("error", event, fields);
67
+ }
68
+ };
69
+ }
70
+ function authSignature(auth) {
71
+ return JSON.stringify({
72
+ issuer: auth.issuer,
73
+ clientId: auth.clientId,
74
+ clientSecret: auth.clientSecret,
75
+ scopes: auth.scopes,
76
+ authorizationEndpoint: auth.authorizationEndpoint,
77
+ tokenEndpoint: auth.tokenEndpoint,
78
+ deviceAuthorizationEndpoint: auth.deviceAuthorizationEndpoint,
79
+ jwksUri: auth.jwksUri,
80
+ redirectPort: auth.redirectPort,
81
+ authFlow: auth.authFlow,
82
+ pkce: auth.pkce,
83
+ subjectTokenSource: auth.subjectTokenSource
84
+ });
85
+ }
86
+ /**
87
+ * Config-time stamp of the project bearer onto `provider.options.headers
88
+ * .Authorization`, mirroring oauth2's `propagateCachedBearer`. This is the
89
+ * config-object handshake that lets `@vymalo/opencode-models-info` (which runs
90
+ * after repo-auth in `plugin`) fetch an OAuth2-protected `meta.modelsInfoUrl`.
91
+ * A user-set `Authorization` always wins here; and a stale stamped value is
92
+ * harmless because `chat.headers` overwrites it per request with a fresh
93
+ * token. Never opens a browser / device-code prompt: on any failure (no cached
94
+ * human token, exchange error) it logs and skips the stamp.
95
+ */
96
+ async function propagateCachedBearer(providerConfig, providerId, plugin, logger) {
97
+ const options = providerConfig.options ??= {};
98
+ const headers = options.headers ??= {};
99
+ const hasUserAuth = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
100
+ if (hasUserAuth) {
101
+ logger.debug("repo_auth_bearer_propagation_skipped_user_set", { providerId });
102
+ return;
103
+ }
104
+ let token;
105
+ try {
106
+ // Config-time warmup is strictly non-interactive: a first-ever login must
107
+ // not block boot on a browser/device-code prompt. The project token is
108
+ // renewed later by the first `chat.headers` request (interactive) or the
109
+ // user's explicit `auth login`. A missing/expired human token throws here
110
+ // and is caught below — `repo_auth_bearer_propagation_skipped_no_token`.
111
+ token = await plugin.resolveProjectToken({ interactive: false });
112
+ } catch (error) {
113
+ logger.debug("repo_auth_bearer_propagation_skipped_no_token", {
114
+ providerId,
115
+ error: error instanceof Error ? error.message : String(error)
116
+ });
117
+ return;
118
+ }
119
+ if (!token.accessToken) {
120
+ logger.debug("repo_auth_bearer_propagation_skipped_empty_token", { providerId });
121
+ return;
122
+ }
123
+ headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
124
+ logger.debug("repo_auth_bearer_propagated_to_provider_headers", { providerId });
125
+ }
126
+ /**
127
+ * Resolve the repo's git identity once per config hook — worktree-aware, read
128
+ * off disk, normalized (userinfo stripped). Log-only in v1: nothing is derived
129
+ * from the remote; the module exists to make the plugin worktree-correct and
130
+ * to leave a repo-auditable trace in the log stream.
131
+ */
132
+ async function logGitIdentity(logger, cwd) {
133
+ const repoRoot = await resolveRepoRoot(cwd);
134
+ if (!repoRoot) {
135
+ logger.trace("repo_auth_remote_missing", {});
136
+ return;
137
+ }
138
+ const remote = await resolveOriginRemote(repoRoot);
139
+ if (!remote) {
140
+ logger.trace("repo_auth_remote_missing", { repoRoot });
141
+ return;
142
+ }
143
+ logger.debug("repo_auth_remote_resolved", {
144
+ repoRoot,
145
+ remote
146
+ });
147
+ }
148
+ /**
149
+ * Walk the provider map, collect every `options.meta.repoAuth` opt-in, and
150
+ * enforce the plugin's guards. Returns the managed providers map (providerId →
151
+ * parsed config), logging `repo_auth_*` events as it goes.
152
+ */
153
+ function collectManagedProviders(config, logger) {
154
+ const managed = new Map();
155
+ const providers = config.provider ?? {};
156
+ const optedIn = [];
157
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
158
+ const options = asRecord(providerConfig.options);
159
+ // A malformed opt-in on one provider must not take down the whole plugin:
160
+ // warn and skip, mirroring the no-op matrix. (oauth2 throws for its own
161
+ // config keys; here a stray/typo'd `meta.repoAuth` on any provider would
162
+ // otherwise reject the entire config hook for every provider.)
163
+ let parsed;
164
+ try {
165
+ parsed = parseRepoAuthOptions(options);
166
+ } catch (error) {
167
+ logger.warn("repo_auth_skipped_malformed", {
168
+ providerId,
169
+ error: error instanceof Error ? error.message : String(error)
170
+ });
171
+ continue;
172
+ }
173
+ if (parsed.kind === "not_opted_in") {
174
+ logger.trace("repo_auth_provider_skipped", { providerId });
175
+ continue;
176
+ }
177
+ if (parsed.kind === "missing_project_id") {
178
+ logger.warn("repo_auth_skipped_no_project_id", { providerId });
179
+ continue;
180
+ }
181
+ if (hasOAuth2Conflict(config, options, providerId)) {
182
+ logger.warn("repo_auth_skipped_oauth2_provider", { providerId });
183
+ continue;
184
+ }
185
+ const entry = {
186
+ projectId: parsed.config.projectId,
187
+ auth: parsed.config.auth,
188
+ authSignature: authSignature(parsed.config.auth)
189
+ };
190
+ optedIn.push({
191
+ providerId,
192
+ config: entry
193
+ });
194
+ }
195
+ // v1 is single-IdP: one identity key (the human) per cache namespace. The
196
+ // human root file is shared across every managed provider, so two opted-in
197
+ // providers MUST resolve to the same IdP or the second would clobber the
198
+ // first's human token. Guard: keep the first IdP group, warn + skip the rest.
199
+ const first = optedIn[0];
200
+ if (first) {
201
+ const selected = new Set([first.config.authSignature]);
202
+ for (const { providerId, config } of optedIn) {
203
+ if (!selected.has(config.authSignature)) {
204
+ logger.warn("repo_auth_multiple_idps_unsupported", { providerId });
205
+ continue;
206
+ }
207
+ logger.trace("repo_auth_provider_opted_in", {
208
+ providerId,
209
+ projectId: config.projectId
210
+ });
211
+ managed.set(providerId, config);
212
+ }
213
+ }
214
+ return managed;
215
+ }
216
+ function runtimeSignature(managed) {
217
+ const sorted = [...managed.entries()].sort(([a], [b]) => a.localeCompare(b));
218
+ return JSON.stringify(sorted.map(([providerId, entry]) => ({
219
+ providerId,
220
+ projectId: entry.projectId,
221
+ authSignature: entry.authSignature
222
+ })));
223
+ }
224
+ export function createOpencodeRepoAuthPlugin(factoryOptions = {}) {
225
+ return async ({ client }) => {
226
+ let currentLogLevel = DEFAULT_LOG_LEVEL;
227
+ const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel);
228
+ const state = {
229
+ pluginByProvider: new Map(),
230
+ signature: undefined
231
+ };
232
+ return {
233
+ config: async (config) => {
234
+ currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL;
235
+ logger.trace("repo_auth_config_hook_start", {
236
+ logLevel: currentLogLevel,
237
+ hostLogLevel: typeof config.logLevel === "string" ? config.logLevel : undefined
238
+ });
239
+ await logGitIdentity(logger, factoryOptions.cwd ?? process.cwd());
240
+ const managed = collectManagedProviders(config, logger);
241
+ logger.trace("repo_auth_config_hook_collected_providers", {
242
+ managedCount: managed.size,
243
+ providerIds: [...managed.keys()]
244
+ });
245
+ if (managed.size === 0) {
246
+ logger.trace("repo_auth_config_hook_no_managed_providers", {});
247
+ state.pluginByProvider.clear();
248
+ state.signature = undefined;
249
+ logger.trace("repo_auth_config_hook_finished", { managedCount: 0 });
250
+ return;
251
+ }
252
+ const signature = runtimeSignature(managed);
253
+ if (state.signature !== signature || state.pluginByProvider.size === 0) {
254
+ logger.trace("repo_auth_runtime_rebuild", {
255
+ reason: state.pluginByProvider.size > 0 ? "signature_changed" : "first_build",
256
+ providerCount: managed.size
257
+ });
258
+ const rebuilt = new Map();
259
+ for (const [providerId, entry] of managed.entries()) {
260
+ try {
261
+ const plugin = new RepoAuthPlugin({
262
+ projectId: entry.projectId,
263
+ auth: entry.auth
264
+ }, {
265
+ logger,
266
+ fetchImpl: factoryOptions.fetchImpl,
267
+ onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
268
+ cacheDir: factoryOptions.cacheDir
269
+ });
270
+ rebuilt.set(providerId, plugin);
271
+ } catch (error) {
272
+ // validateAuthConfig inside the constructor can still reject
273
+ // (e.g. client_credentials without clientSecret). Fail that one
274
+ // provider, keep the rest — never abort the whole config hook.
275
+ logger.warn("repo_auth_skipped_invalid_auth", {
276
+ providerId,
277
+ error: error instanceof Error ? error.message : String(error)
278
+ });
279
+ }
280
+ }
281
+ state.pluginByProvider = rebuilt;
282
+ state.signature = signature;
283
+ } else {
284
+ logger.trace("repo_auth_runtime_reused", { providerCount: managed.size });
285
+ }
286
+ const providers = config.provider ??= {};
287
+ await Promise.all([...state.pluginByProvider.entries()].map(([providerId, plugin]) => {
288
+ const providerConfig = providers[providerId];
289
+ if (!providerConfig) {
290
+ return undefined;
291
+ }
292
+ return propagateCachedBearer(providerConfig, providerId, plugin, logger);
293
+ }));
294
+ logger.trace("repo_auth_config_hook_finished", { managedCount: state.pluginByProvider.size });
295
+ },
296
+ "chat.headers": async (input, output) => {
297
+ const providerId = input.model?.providerID ?? input.provider?.info?.id;
298
+ const plugin = providerId ? state.pluginByProvider.get(providerId) : undefined;
299
+ if (!providerId || !plugin) {
300
+ logger.trace("repo_auth_chat_headers_skipped", {
301
+ providerId,
302
+ managed: providerId ? state.pluginByProvider.has(providerId) : false
303
+ });
304
+ return;
305
+ }
306
+ // Fail closed: on any exchange failure we inject NO header — the
307
+ // request goes out without a bearer and the gateway 401s, matching the
308
+ // SPI's fail-closed semantics. The failure itself is logged at error
309
+ // level by `resolveProjectToken` (`repo_auth_exchange_failed`); here we
310
+ // must not let it escape the hook or the whole chat request would error
311
+ // instead of degrading to the gateway's 401.
312
+ try {
313
+ const token = await plugin.resolveProjectToken({ interactive: true });
314
+ output.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
315
+ logger.trace("repo_auth_chat_headers_bearer_injected", {
316
+ providerId,
317
+ present: Boolean(token.accessToken),
318
+ tokenType: token.tokenType || "Bearer"
319
+ });
320
+ } catch {
321
+ logger.trace("repo_auth_chat_headers_no_bearer", { providerId });
322
+ return;
323
+ }
324
+ }
325
+ };
326
+ };
327
+ }
328
+ export const OpencodeRepoAuthPlugin = createOpencodeRepoAuthPlugin();
329
+ export default OpencodeRepoAuthPlugin;
330
+
331
+ //# sourceMappingURL=opencode.js.map
@@ -0,0 +1 @@
1
+ {"mappings":"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","names":[],"sources":["../src/opencode.ts"],"version":3,"file":"opencode.js","sourceRoot":""}
@@ -0,0 +1,100 @@
1
+ import { type Logger, type TokenSet } from "@vymalo/opencode-auth-core/lib";
2
+ import type { RepoAuthConfig } from "./config.js";
3
+ /**
4
+ * The plugin is identity-keyed by the *human* — the single person whose one
5
+ * interactive login (device-code / authorization-code) grounds every project
6
+ * exchange. v1 is single-IdP, so the identity is a constant; if multi-IdP ever
7
+ * lands, derive `issuer|clientId` instead.
8
+ */
9
+ export declare const HUMAN_IDENTITY = "human";
10
+ export declare const DEFAULT_CACHE_NAMESPACE = "opencode-repo-auth";
11
+ export interface RepoAuthPluginOptions {
12
+ logger?: Logger;
13
+ fetchImpl?: typeof fetch;
14
+ onAuthorizationUrl?: (url: string) => Promise<void> | void;
15
+ /** Override the cache root (defaults to the OS cache dir convention). */
16
+ cacheDir?: string;
17
+ tokenExpirySkewMs?: number;
18
+ }
19
+ /**
20
+ * Whether a cached project token is usable. Deliberately *not* routed through
21
+ * auth-core's `OAuthClient.isTokenValid`: that check treats a missing
22
+ * `expiresAt` as non-expiring for interactive flows, but the project token has
23
+ * **no refresh token** — an undefined or passed lifetime must trigger a
24
+ * re-exchange (the machine-flow policy), otherwise a 401 after the server
25
+ * revoked it becomes a permanent failure rather than a one round trip.
26
+ */
27
+ export declare function isProjectTokenUsable(token: TokenSet | undefined, skewMs: number): token is TokenSet;
28
+ /**
29
+ * Repo-auth runtime over `@vymalo/opencode-auth-core`'s `TokenRuntime`.
30
+ * Owns two token kinds, both persisted by auth-core's `FileCacheStore` under
31
+ * the plugin's cache namespace (OS cache dir, `0o600`, atomic rename):
32
+ *
33
+ * - the **human root** (`<cacheDir>/human.json`) — what `ensure` /
34
+ * `refresh` produces; carries the `offline_access` refresh token that makes
35
+ * re-exchange automatic ("model b"); never sent to the gateway.
36
+ * - the **project token** (`<cacheDir>/human-<hash(projectId)>.json`) — the
37
+ * SPI-sealed RFC 8693 exchange result consumed by the gateway; short-lived,
38
+ * no refresh token, so renewal is always a fresh exchange from the human
39
+ * root, never a "refreshed" project token (which would lose the project
40
+ * claims).
41
+ *
42
+ * The plugin keeps only in-memory state; no `cache.ts` of its own.
43
+ */
44
+ export declare class RepoAuthPlugin {
45
+ readonly config: RepoAuthConfig;
46
+ private readonly runtime;
47
+ private readonly logger;
48
+ private readonly tokenExpirySkewMs;
49
+ private inFlightExchange?;
50
+ constructor(config: RepoAuthConfig, options?: RepoAuthPluginOptions);
51
+ get projectId(): string;
52
+ /**
53
+ * Ensure the human root token. Config-time callers pass `{interactive:false}`
54
+ * so a first-ever login never blocks boot on a browser/device-code prompt;
55
+ * per-request callers allow it (the first chat is the natural moment to log
56
+ * in). A stale-but-refreshable token is refreshed silently via its
57
+ * `offline_access` refresh token.
58
+ */
59
+ ensureHumanToken(options?: {
60
+ interactive?: boolean;
61
+ }): Promise<TokenSet>;
62
+ /** Non-network read of the cached project token, if any. */
63
+ getCachedProjectToken(): Promise<TokenSet | undefined>;
64
+ /**
65
+ * Resolve a *usable* project token — "model b":
66
+ *
67
+ * cached usable? ──yes──▶ return it (repo_auth_exchange_cache_hit)
68
+ * │ no
69
+ * ▼
70
+ * ensure human root (refresh-only; interactive never used here)
71
+ * ▼
72
+ * exchangeTo(projectId, humanToken, { project_id }) ← ONE POST, no audience
73
+ * ▼
74
+ * cache under human-<hash(projectId)> + return
75
+ *
76
+ * The project token is **never refreshed**; re-exchange is the canonical
77
+ * renewal. Fails closed: an exchange failure (non-member, resolver error,
78
+ * network) throws `repo_auth_exchange_failed` — the caller injects no header
79
+ * and the gateway 401s, which is correct (matches the SPI's fail-closed
80
+ * semantics). The plugin never invents a token.
81
+ *
82
+ * `interactive` controls only the human-root derivation for a *re-exchange*:
83
+ * config-time callers keep it `false` (a first-ever login must not block
84
+ * boot), while `chat.headers` callers default to `true` so the first chat
85
+ * request can open the device-code / browser flow.
86
+ *
87
+ * Concurrent callers (parallel chat headers, config warmup racing a request)
88
+ * share the in-flight exchange: the first cache-missing caller kicks it off
89
+ * and the rest await the same promise, so there is at most one exchange POST
90
+ * and at most one interactive prompt per cache-miss window.
91
+ */
92
+ resolveProjectToken(options?: {
93
+ interactive?: boolean;
94
+ }): Promise<TokenSet>;
95
+ private performExchange;
96
+ /** Drop the on-disk human + project tokens for this identity. */
97
+ reset(): Promise<void>;
98
+ }
99
+ /** Absolute path to the repo-auth cache directory (for diagnostics / tests). */
100
+ export declare function repoAuthCacheDir(cacheRoot: string): string;
package/dist/plugin.js ADDED
@@ -0,0 +1,161 @@
1
+ import { join } from "node:path";
2
+ import { createJsonConsoleLogger, DEFAULT_TOKEN_EXPIRY_SKEW_MS, resolveCacheRoot, TokenRuntime, validateAuthConfig } from "@vymalo/opencode-auth-core/lib";
3
+ /**
4
+ * The plugin is identity-keyed by the *human* — the single person whose one
5
+ * interactive login (device-code / authorization-code) grounds every project
6
+ * exchange. v1 is single-IdP, so the identity is a constant; if multi-IdP ever
7
+ * lands, derive `issuer|clientId` instead.
8
+ */
9
+ export const HUMAN_IDENTITY = "human";
10
+ export const DEFAULT_CACHE_NAMESPACE = "opencode-repo-auth";
11
+ /**
12
+ * Whether a cached project token is usable. Deliberately *not* routed through
13
+ * auth-core's `OAuthClient.isTokenValid`: that check treats a missing
14
+ * `expiresAt` as non-expiring for interactive flows, but the project token has
15
+ * **no refresh token** — an undefined or passed lifetime must trigger a
16
+ * re-exchange (the machine-flow policy), otherwise a 401 after the server
17
+ * revoked it becomes a permanent failure rather than a one round trip.
18
+ */
19
+ export function isProjectTokenUsable(token, skewMs) {
20
+ if (!token?.accessToken) {
21
+ return false;
22
+ }
23
+ if (token.expiresAt === undefined) {
24
+ return false;
25
+ }
26
+ return Date.now() + skewMs < token.expiresAt;
27
+ }
28
+ /**
29
+ * Repo-auth runtime over `@vymalo/opencode-auth-core`'s `TokenRuntime`.
30
+ * Owns two token kinds, both persisted by auth-core's `FileCacheStore` under
31
+ * the plugin's cache namespace (OS cache dir, `0o600`, atomic rename):
32
+ *
33
+ * - the **human root** (`<cacheDir>/human.json`) — what `ensure` /
34
+ * `refresh` produces; carries the `offline_access` refresh token that makes
35
+ * re-exchange automatic ("model b"); never sent to the gateway.
36
+ * - the **project token** (`<cacheDir>/human-<hash(projectId)>.json`) — the
37
+ * SPI-sealed RFC 8693 exchange result consumed by the gateway; short-lived,
38
+ * no refresh token, so renewal is always a fresh exchange from the human
39
+ * root, never a "refreshed" project token (which would lose the project
40
+ * claims).
41
+ *
42
+ * The plugin keeps only in-memory state; no `cache.ts` of its own.
43
+ */
44
+ export class RepoAuthPlugin {
45
+ config;
46
+ runtime;
47
+ logger;
48
+ tokenExpirySkewMs;
49
+ inFlightExchange;
50
+ constructor(config, options = {}) {
51
+ this.config = config;
52
+ this.logger = options.logger ?? createJsonConsoleLogger("info");
53
+ this.tokenExpirySkewMs = typeof options.tokenExpirySkewMs === "number" && Number.isFinite(options.tokenExpirySkewMs) && options.tokenExpirySkewMs > 0 ? options.tokenExpirySkewMs : DEFAULT_TOKEN_EXPIRY_SKEW_MS;
54
+ this.runtime = new TokenRuntime(
55
+ HUMAN_IDENTITY,
56
+ // Validate once at construction so a malformed auth block fails early
57
+ // with auth-core's field-level errors (defaults applied: authFlow →
58
+ // authorization_code, pkce → true).
59
+ validateAuthConfig(config.auth),
60
+ {
61
+ logger: this.logger,
62
+ fetchImpl: options.fetchImpl,
63
+ onAuthorizationUrl: options.onAuthorizationUrl,
64
+ cacheDir: options.cacheDir ?? join(resolveCacheRoot(), DEFAULT_CACHE_NAMESPACE),
65
+ tokenExpirySkewMs: this.tokenExpirySkewMs
66
+ }
67
+ );
68
+ }
69
+ get projectId() {
70
+ return this.config.projectId;
71
+ }
72
+ /**
73
+ * Ensure the human root token. Config-time callers pass `{interactive:false}`
74
+ * so a first-ever login never blocks boot on a browser/device-code prompt;
75
+ * per-request callers allow it (the first chat is the natural moment to log
76
+ * in). A stale-but-refreshable token is refreshed silently via its
77
+ * `offline_access` refresh token.
78
+ */
79
+ async ensureHumanToken(options = {}) {
80
+ const token = await this.runtime.ensure({ interactive: options.interactive });
81
+ this.logger.debug("repo_auth_human_token_ensured", { present: Boolean(token.accessToken) });
82
+ return token;
83
+ }
84
+ /** Non-network read of the cached project token, if any. */
85
+ async getCachedProjectToken() {
86
+ return this.runtime.getExchangedByKey(this.projectId);
87
+ }
88
+ /**
89
+ * Resolve a *usable* project token — "model b":
90
+ *
91
+ * cached usable? ──yes──▶ return it (repo_auth_exchange_cache_hit)
92
+ * │ no
93
+ * ▼
94
+ * ensure human root (refresh-only; interactive never used here)
95
+ * ▼
96
+ * exchangeTo(projectId, humanToken, { project_id }) ← ONE POST, no audience
97
+ * ▼
98
+ * cache under human-<hash(projectId)> + return
99
+ *
100
+ * The project token is **never refreshed**; re-exchange is the canonical
101
+ * renewal. Fails closed: an exchange failure (non-member, resolver error,
102
+ * network) throws `repo_auth_exchange_failed` — the caller injects no header
103
+ * and the gateway 401s, which is correct (matches the SPI's fail-closed
104
+ * semantics). The plugin never invents a token.
105
+ *
106
+ * `interactive` controls only the human-root derivation for a *re-exchange*:
107
+ * config-time callers keep it `false` (a first-ever login must not block
108
+ * boot), while `chat.headers` callers default to `true` so the first chat
109
+ * request can open the device-code / browser flow.
110
+ *
111
+ * Concurrent callers (parallel chat headers, config warmup racing a request)
112
+ * share the in-flight exchange: the first cache-missing caller kicks it off
113
+ * and the rest await the same promise, so there is at most one exchange POST
114
+ * and at most one interactive prompt per cache-miss window.
115
+ */
116
+ async resolveProjectToken(options = {}) {
117
+ const cached = await this.getCachedProjectToken();
118
+ if (isProjectTokenUsable(cached, this.tokenExpirySkewMs)) {
119
+ this.logger.trace("repo_auth_exchange_cache_hit", { projectId: this.projectId });
120
+ return cached;
121
+ }
122
+ this.logger.trace("repo_auth_exchange_cache_miss", { projectId: this.projectId });
123
+ if (this.inFlightExchange) {
124
+ return this.inFlightExchange;
125
+ }
126
+ const exchange = this.performExchange(options);
127
+ this.inFlightExchange = exchange;
128
+ try {
129
+ return await exchange;
130
+ } finally {
131
+ if (this.inFlightExchange === exchange) {
132
+ this.inFlightExchange = undefined;
133
+ }
134
+ }
135
+ }
136
+ async performExchange(options) {
137
+ const human = await this.ensureHumanToken({ interactive: options.interactive });
138
+ this.logger.info("repo_auth_exchange_started", { projectId: this.projectId });
139
+ try {
140
+ const exchanged = await this.runtime.exchangeTo(this.projectId, human.accessToken, { project_id: this.projectId });
141
+ this.logger.info("repo_auth_exchange_success", { projectId: this.projectId });
142
+ return exchanged;
143
+ } catch (error) {
144
+ this.logger.error("repo_auth_exchange_failed", {
145
+ projectId: this.projectId,
146
+ error: error instanceof Error ? error.message : String(error)
147
+ });
148
+ throw error;
149
+ }
150
+ }
151
+ /** Drop the on-disk human + project tokens for this identity. */
152
+ async reset() {
153
+ await this.runtime.reset();
154
+ }
155
+ }
156
+ /** Absolute path to the repo-auth cache directory (for diagnostics / tests). */
157
+ export function repoAuthCacheDir(cacheRoot) {
158
+ return join(cacheRoot, DEFAULT_CACHE_NAMESPACE);
159
+ }
160
+
161
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAAA,SAAS,YAAY;AAErB,SACE,yBACA,8BACA,kBACA,cACA,0BAGK;;;;;;;AAUP,OAAO,MAAM,iBAAiB;AAE9B,OAAO,MAAM,0BAA0B;;;;;;;;;AAmBvC,OAAO,SAAS,qBACd,OACA,QACmB;CACnB,IAAI,CAAC,OAAO,aAAa;EACvB,OAAO;CACT;CACA,IAAI,MAAM,cAAc,WAAW;EACjC,OAAO;CACT;CACA,OAAO,KAAK,IAAI,IAAI,SAAS,MAAM;AACrC;;;;;;;;;;;;;;;;;AAkBA,OAAO,MAAM,eAAe;CAOf;CANX,AAAiB;CACjB,AAAiB;CACjB,AAAiB;CACjB,AAAQ;CAER,YACE,AAAS,QACT,UAAiC,CAAC,GAClC;EAFS;EAGT,KAAK,SAAS,QAAQ,UAAU,wBAAwB,MAAM;EAC9D,KAAK,oBACH,OAAO,QAAQ,sBAAsB,YACrC,OAAO,SAAS,QAAQ,iBAAiB,KACzC,QAAQ,oBAAoB,IACxB,QAAQ,oBACR;EAEN,KAAK,UAAU,IAAI;GACjB;;;;GAIA,mBAAmB,OAAO,IAAI;GAC9B;IACE,QAAQ,KAAK;IACb,WAAW,QAAQ;IACnB,oBAAoB,QAAQ;IAC5B,UAAU,QAAQ,YAAY,KAAK,iBAAiB,GAAG,uBAAuB;IAC9E,mBAAmB,KAAK;GAC1B;EACF;CACF;CAEA,IAAI,YAAoB;EACtB,OAAO,KAAK,OAAO;CACrB;;;;;;;;CASA,MAAM,iBAAiB,UAAqC,CAAC,GAAsB;EACjF,MAAM,QAAQ,MAAM,KAAK,QAAQ,OAAO,EAAE,aAAa,QAAQ,YAAY,CAAC;EAC5E,KAAK,OAAO,MAAM,iCAAiC,EACjD,SAAS,QAAQ,MAAM,WAAW,EACpC,CAAC;EACD,OAAO;CACT;;CAGA,MAAM,wBAAuD;EAC3D,OAAO,KAAK,QAAQ,kBAAkB,KAAK,SAAS;CACtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BA,MAAM,oBAAoB,UAAqC,CAAC,GAAsB;EACpF,MAAM,SAAS,MAAM,KAAK,sBAAsB;EAChD,IAAI,qBAAqB,QAAQ,KAAK,iBAAiB,GAAG;GACxD,KAAK,OAAO,MAAM,gCAAgC,EAAE,WAAW,KAAK,UAAU,CAAC;GAC/E,OAAO;EACT;EACA,KAAK,OAAO,MAAM,iCAAiC,EAAE,WAAW,KAAK,UAAU,CAAC;EAEhF,IAAI,KAAK,kBAAkB;GACzB,OAAO,KAAK;EACd;EAEA,MAAM,WAAW,KAAK,gBAAgB,OAAO;EAC7C,KAAK,mBAAmB;EACxB,IAAI;GACF,OAAO,MAAM;EACf,UAAU;GACR,IAAI,KAAK,qBAAqB,UAAU;IACtC,KAAK,mBAAmB;GAC1B;EACF;CACF;CAEA,MAAc,gBAAgB,SAAuD;EACnF,MAAM,QAAQ,MAAM,KAAK,iBAAiB,EAAE,aAAa,QAAQ,YAAY,CAAC;EAC9E,KAAK,OAAO,KAAK,8BAA8B,EAAE,WAAW,KAAK,UAAU,CAAC;EAC5E,IAAI;GACF,MAAM,YAAY,MAAM,KAAK,QAAQ,WAAW,KAAK,WAAW,MAAM,aAAa,EACjF,YAAY,KAAK,UACnB,CAAC;GACD,KAAK,OAAO,KAAK,8BAA8B,EAAE,WAAW,KAAK,UAAU,CAAC;GAC5E,OAAO;EACT,SAAS,OAAO;GACd,KAAK,OAAO,MAAM,6BAA6B;IAC7C,WAAW,KAAK;IAChB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC9D,CAAC;GACD,MAAM;EACR;CACF;;CAGA,MAAM,QAAuB;EAC3B,MAAM,KAAK,QAAQ,MAAM;CAC3B;AACF;;AAGA,OAAO,SAAS,iBAAiB,WAA2B;CAC1D,OAAO,KAAK,WAAW,uBAAuB;AAChD","names":[],"sources":["../src/plugin.ts"],"version":3,"file":"plugin.js","sourceRoot":""}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@vymalo/opencode-repo-auth",
3
+ "version": "0.14.1",
4
+ "description": "OpenCode plugin that gives local-dev requests repo-as-project attribution: a developer logs in once as themselves and every gateway request from an enrolled repo carries a project-scoped bearer.",
5
+ "license": "MIT",
6
+ "author": "vymalo contributors",
7
+ "homepage": "https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit.git",
11
+ "directory": "packages/opencode-repo-auth"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "oauth2",
20
+ "token-exchange",
21
+ "project",
22
+ "gateway",
23
+ "ai-sdk"
24
+ ],
25
+ "type": "module",
26
+ "main": "dist/index.js",
27
+ "types": "dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./lib": {
34
+ "types": "./dist/lib.d.ts",
35
+ "import": "./dist/lib.js"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "sideEffects": false,
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@opencode-ai/plugin": "1.15.10",
51
+ "@vymalo/opencode-auth-core": "0.14.1"
52
+ },
53
+ "devDependencies": {
54
+ "vite": "^8.2.1",
55
+ "vitest": "^4.1.7"
56
+ },
57
+ "scripts": {
58
+ "build": "node ../../scripts/build-package.mjs",
59
+ "lint": "biome lint .",
60
+ "typecheck": "tsc -p tsconfig.json --noEmit",
61
+ "test": "vitest run",
62
+ "test:integration": "vitest run --config vitest.integration.config.ts",
63
+ "coverage": "vitest run --coverage",
64
+ "format": "biome format --write .",
65
+ "format:check": "biome format ."
66
+ }
67
+ }