@cortexkit/common-auth 0.2.7 → 0.2.8

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,23 @@
1
+ /**
2
+ * Why an OpenCode 2 auth hook refused a request.
3
+ *
4
+ * - `no-account`: the adapter had no account to offer for this request, so
5
+ * the request is stopped before the host picks a transport.
6
+ * - `host-credential-on-wire`: after every rewrite, a header still carried
7
+ * one of the host's placeholder credentials. Sending it would leak a
8
+ * non-routable value to the provider and fail with a confusing error.
9
+ */
10
+ export type OpenCode2AuthFailureKind = 'no-account' | 'host-credential-on-wire';
11
+ export declare class OpenCode2AuthError extends Error {
12
+ readonly kind: OpenCode2AuthFailureKind;
13
+ readonly providerID: string;
14
+ readonly sessionID: string;
15
+ readonly requestKind: string;
16
+ constructor(details: {
17
+ kind: OpenCode2AuthFailureKind;
18
+ providerID: string;
19
+ sessionID: string;
20
+ requestKind: string;
21
+ message?: string;
22
+ });
23
+ }
@@ -0,0 +1,15 @@
1
+ export class OpenCode2AuthError extends Error {
2
+ kind;
3
+ providerID;
4
+ sessionID;
5
+ requestKind;
6
+ constructor(details) {
7
+ super(details.message ??
8
+ `${details.providerID} ${details.requestKind} request refused (${details.kind})`);
9
+ this.name = 'OpenCode2AuthError';
10
+ this.kind = details.kind;
11
+ this.providerID = details.providerID;
12
+ this.sessionID = details.sessionID;
13
+ this.requestKind = details.requestKind;
14
+ }
15
+ }
@@ -1 +1,8 @@
1
- export {};
1
+ export type { OpenCode2AuthFailureKind } from './errors.js';
2
+ export { OpenCode2AuthError } from './errors.js';
3
+ export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
4
+ export type { FormAnswer, PoolAuthorization, PoolLoginMethod, RegisterOpenCode2AuthMethodsOptions, } from './integration.js';
5
+ export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
6
+ export type { ServerSentEvent } from './sse.js';
7
+ export { watchServerSentEvents } from './sse.js';
8
+ export type { AccountRequest, ChooseAccountInput, EventVerdict, HeaderEdits, HostError, InstallOpenCode2AuthOptions, LimitSignal, OpenCode2AuthAdapter, OpenCode2AuthEventName, OpenCode2AuthEvents, OpenCode2AuthInstallation, OpenCode2AuthLogger, OpenCode2HookContext, RequestKind, RequestScope, RetryReason, SelectingHook, Transport, } from './types.js';
@@ -1,2 +1,4 @@
1
- // Placeholder so the export resolves until this subpath is built.
2
- export {};
1
+ export { OpenCode2AuthError } from './errors.js';
2
+ export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
3
+ export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
4
+ export { watchServerSentEvents } from './sse.js';
@@ -0,0 +1,19 @@
1
+ import type { HeaderEdits, InstallOpenCode2AuthOptions, OpenCode2AuthAdapter, OpenCode2AuthInstallation, OpenCode2HookContext } from './types.js';
2
+ export declare const DEFAULT_MAX_RECORDS = 512;
3
+ /** Applies edits to a plain header record, replacing every spelling of each name. */
4
+ export declare function applyHeaderEdits(target: Record<string, string>, edits: HeaderEdits): void;
5
+ /**
6
+ * Installs multi-account auth on OpenCode 2's own provider drivers. Every
7
+ * hook is scoped to `adapter.providerID`:
8
+ *
9
+ * - `model.request` picks the account for the request's `sessionID:kind` and
10
+ * sets its headers;
11
+ * - `http.request` and `experimental.ws.handshake` set them again, because
12
+ * the host applies its own credential after `model.request`;
13
+ * - `http.response` and `experimental.ws.receive` read quota, refusals and
14
+ * whether output has started, attributed through the record above;
15
+ * - `retry` asks the host to retry at once when an account was refused
16
+ * before any output, so `model.request` runs again and can pick another
17
+ * account, and refuses to retry once output has started.
18
+ */
19
+ export declare function installOpenCode2Auth<Q>(ctx: OpenCode2HookContext, adapter: OpenCode2AuthAdapter<Q>, options?: InstallOpenCode2AuthOptions): Promise<OpenCode2AuthInstallation<Q>>;
@@ -0,0 +1,415 @@
1
+ import { OpenCode2AuthError } from './errors.js';
2
+ import { placeholderSecret } from './integration.js';
3
+ import { watchServerSentEvents } from './sse.js';
4
+ export const DEFAULT_MAX_RECORDS = 512;
5
+ const keyOf = (sessionID, kind) => JSON.stringify([sessionID, kind]);
6
+ const scopeOf = (draft) => ({
7
+ providerID: draft.model.providerID,
8
+ modelID: draft.model.id,
9
+ sessionID: draft.sessionID,
10
+ agent: draft.agent,
11
+ kind: draft.kind,
12
+ });
13
+ /** Applies edits to a plain header record, replacing every spelling of each name. */
14
+ export function applyHeaderEdits(target, edits) {
15
+ for (const [name, value] of Object.entries(edits)) {
16
+ const lower = name.toLowerCase();
17
+ for (const existing of Object.keys(target)) {
18
+ if (existing.toLowerCase() === lower)
19
+ delete target[existing];
20
+ }
21
+ if (value !== null)
22
+ target[name] = value;
23
+ }
24
+ }
25
+ function applyHeaderEditsTo(target, edits) {
26
+ for (const [name, value] of Object.entries(edits)) {
27
+ if (value === null)
28
+ target.delete(name);
29
+ else
30
+ target.set(name, value);
31
+ }
32
+ }
33
+ /**
34
+ * Installs multi-account auth on OpenCode 2's own provider drivers. Every
35
+ * hook is scoped to `adapter.providerID`:
36
+ *
37
+ * - `model.request` picks the account for the request's `sessionID:kind` and
38
+ * sets its headers;
39
+ * - `http.request` and `experimental.ws.handshake` set them again, because
40
+ * the host applies its own credential after `model.request`;
41
+ * - `http.response` and `experimental.ws.receive` read quota, refusals and
42
+ * whether output has started, attributed through the record above;
43
+ * - `retry` asks the host to retry at once when an account was refused
44
+ * before any output, so `model.request` runs again and can pick another
45
+ * account, and refuses to retry once output has started.
46
+ */
47
+ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
48
+ const { providerID } = adapter;
49
+ const logger = options.logger;
50
+ const maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_MAX_RECORDS);
51
+ const forbidden = (options.hostCredentials ?? [placeholderSecret(providerID)]).filter((value) => value !== '');
52
+ const records = new Map();
53
+ const byRequest = new WeakMap();
54
+ const listeners = new Map();
55
+ let seq = 0;
56
+ const warn = (message, data) => {
57
+ try {
58
+ logger?.warn(message, data);
59
+ }
60
+ catch { }
61
+ };
62
+ const describe = (error) => error instanceof Error ? error.message : String(error);
63
+ const emit = async (event, payload) => {
64
+ const set = listeners.get(event);
65
+ if (!set || set.size === 0)
66
+ return;
67
+ await Promise.all([...set].map(async (listener) => {
68
+ try {
69
+ await listener(payload);
70
+ }
71
+ catch (error) {
72
+ warn('opencode2 auth listener threw; the request is unaffected', {
73
+ event,
74
+ error: describe(error),
75
+ });
76
+ }
77
+ }));
78
+ };
79
+ const remember = (rec) => {
80
+ const key = keyOf(rec.scope.sessionID, rec.scope.kind);
81
+ records.delete(key);
82
+ records.set(key, rec);
83
+ while (records.size > maxRecords) {
84
+ const oldest = records.keys().next().value;
85
+ if (oldest === undefined)
86
+ break;
87
+ records.delete(oldest);
88
+ }
89
+ };
90
+ const accountOf = (rec) => rec.accountId === undefined
91
+ ? undefined
92
+ : { ...rec.scope, accountId: rec.accountId };
93
+ const noAccount = (scope) => new OpenCode2AuthError({
94
+ kind: 'no-account',
95
+ providerID,
96
+ sessionID: scope.sessionID,
97
+ requestKind: scope.kind,
98
+ });
99
+ const guard = (scope, headers) => {
100
+ for (const [name, value] of headers) {
101
+ if (forbidden.some((secret) => value.includes(secret))) {
102
+ throw new OpenCode2AuthError({
103
+ kind: 'host-credential-on-wire',
104
+ providerID,
105
+ sessionID: scope.sessionID,
106
+ requestKind: scope.kind,
107
+ message: `${providerID} ${scope.kind} request still carries the host's placeholder credential in ${name}; the adapter's account headers must replace it`,
108
+ });
109
+ }
110
+ }
111
+ };
112
+ const select = async (scope, hook) => {
113
+ const prior = records.get(keyOf(scope.sessionID, scope.kind));
114
+ const input = {
115
+ ...scope,
116
+ ...(prior?.accountId === undefined
117
+ ? {}
118
+ : { previousAccountId: prior.accountId }),
119
+ ...(prior?.rerouteFrom === undefined
120
+ ? {}
121
+ : { rerouteFrom: prior.rerouteFrom }),
122
+ };
123
+ const accountId = await adapter.chooseAccount(input);
124
+ const headers = accountId === undefined
125
+ ? {}
126
+ : await adapter.accountHeaders({ ...scope, accountId });
127
+ const rec = {
128
+ scope,
129
+ accountId,
130
+ headers,
131
+ seq: ++seq,
132
+ outputStarted: false,
133
+ };
134
+ remember(rec);
135
+ if (accountId !== undefined) {
136
+ void emit('select', {
137
+ ...scope,
138
+ accountId,
139
+ hook,
140
+ ...(input.previousAccountId === undefined
141
+ ? {}
142
+ : { previousAccountId: input.previousAccountId }),
143
+ ...(input.rerouteFrom === undefined
144
+ ? {}
145
+ : { rerouteFrom: input.rerouteFrom }),
146
+ });
147
+ }
148
+ return rec;
149
+ };
150
+ // The transport hooks normally follow `model.request`; choosing here too
151
+ // keeps a request that skipped it from going out under the host credential.
152
+ const ensure = async (scope, hook) => records.get(keyOf(scope.sessionID, scope.kind)) ??
153
+ (await select(scope, hook));
154
+ const noteLimit = (rec, signal, via) => {
155
+ const account = accountOf(rec);
156
+ if (!account || rec.limit)
157
+ return;
158
+ rec.limit = {
159
+ signal,
160
+ delivered: emit('limit', {
161
+ ...account,
162
+ via,
163
+ limit: signal,
164
+ outputStarted: rec.outputStarted,
165
+ }),
166
+ };
167
+ };
168
+ const noteQuota = (rec, transport, quota, status) => {
169
+ const account = accountOf(rec);
170
+ if (!account)
171
+ return;
172
+ void emit('quota', {
173
+ ...account,
174
+ transport,
175
+ ...(status === undefined ? {} : { status }),
176
+ quota,
177
+ });
178
+ };
179
+ const inspect = (rec, transport, data, event) => {
180
+ const verdict = adapter.inspectEvent?.(event === undefined ? { transport, data } : { transport, data, event });
181
+ if (!verdict)
182
+ return;
183
+ if (verdict.quota !== undefined)
184
+ noteQuota(rec, transport, verdict.quota);
185
+ if (verdict.outputStarted)
186
+ rec.outputStarted = true;
187
+ if (verdict.limit)
188
+ noteLimit(rec, verdict.limit, transport);
189
+ };
190
+ const pickForRetry = (sessionID) => {
191
+ let refused;
192
+ let primary;
193
+ let latest;
194
+ for (const rec of records.values()) {
195
+ if (rec.scope.sessionID !== sessionID)
196
+ continue;
197
+ if (!latest || rec.seq > latest.seq)
198
+ latest = rec;
199
+ if (rec.scope.kind === 'primary')
200
+ primary = rec;
201
+ if (rec.limit && !rec.rerouteFrom && (!refused || rec.seq > refused.seq))
202
+ refused = rec;
203
+ }
204
+ return refused ?? primary ?? latest;
205
+ };
206
+ const scoped = { providerID };
207
+ const registrations = [
208
+ await ctx.session.hook('model.request', async (draft) => {
209
+ const rec = await select(scopeOf(draft), 'model.request');
210
+ if (rec.accountId === undefined)
211
+ throw noAccount(rec.scope);
212
+ applyHeaderEdits(draft.headers, rec.headers);
213
+ }, scoped),
214
+ await ctx.session.hook('http.request', async (draft) => {
215
+ const rec = await ensure(scopeOf(draft), 'http.request');
216
+ const account = accountOf(rec);
217
+ if (!account)
218
+ throw noAccount(rec.scope);
219
+ let request = draft.request;
220
+ if (adapter.rewriteRequest) {
221
+ request =
222
+ (await adapter.rewriteRequest({ ...account, request })) ?? request;
223
+ }
224
+ const headers = new Headers(request.headers);
225
+ applyHeaderEditsTo(headers, rec.headers);
226
+ guard(rec.scope, headers.entries());
227
+ const final = new Request(request, { headers });
228
+ byRequest.set(final, rec);
229
+ draft.request = final;
230
+ }, scoped),
231
+ await ctx.session.hook('http.response', async (draft) => {
232
+ const rec = byRequest.get(draft.request) ??
233
+ records.get(keyOf(draft.sessionID, draft.kind));
234
+ const account = rec && accountOf(rec);
235
+ if (!rec || !account)
236
+ return;
237
+ const original = draft.response;
238
+ const quota = adapter.quotaFromHeaders?.(original.headers, original.status);
239
+ if (quota !== undefined)
240
+ noteQuota(rec, 'http', quota, original.status);
241
+ if (!original.ok && adapter.limitFromResponse) {
242
+ const signal = await adapter.limitFromResponse({
243
+ status: original.status,
244
+ headers: original.headers,
245
+ body: () => original.clone().text(),
246
+ });
247
+ if (signal)
248
+ noteLimit(rec, signal, 'http');
249
+ }
250
+ let response = original;
251
+ if (original.body && adapter.inspectEvent) {
252
+ const watch = watchServerSentEvents((event) => inspect(rec, 'http', event.data, event.event), (error) => warn('opencode2 auth event inspection threw', {
253
+ error: describe(error),
254
+ }));
255
+ response = new Response(original.body.pipeThrough(watch), {
256
+ status: original.status,
257
+ statusText: original.statusText,
258
+ headers: original.headers,
259
+ });
260
+ }
261
+ if (adapter.rewriteResponse) {
262
+ response =
263
+ (await adapter.rewriteResponse({
264
+ ...account,
265
+ request: draft.request,
266
+ response,
267
+ })) ?? response;
268
+ }
269
+ if (response !== original)
270
+ draft.response = response;
271
+ }, scoped),
272
+ await ctx.session.hook('experimental.ws.handshake', async (draft) => {
273
+ const rec = await ensure(scopeOf(draft), 'experimental.ws.handshake');
274
+ const account = accountOf(rec);
275
+ if (!account)
276
+ throw noAccount(rec.scope);
277
+ applyHeaderEdits(draft.headers, rec.headers);
278
+ const url = adapter.rewriteHandshakeURL?.({
279
+ ...account,
280
+ url: draft.url,
281
+ });
282
+ if (url !== undefined)
283
+ draft.url = url;
284
+ guard(rec.scope, Object.entries(draft.headers));
285
+ }, scoped),
286
+ await ctx.session.hook('experimental.ws.receive', (draft) => {
287
+ const rec = records.get(keyOf(draft.sessionID, draft.kind));
288
+ if (!rec || rec.accountId === undefined)
289
+ return;
290
+ try {
291
+ inspect(rec, 'ws', draft.frame);
292
+ }
293
+ catch (error) {
294
+ warn('opencode2 auth frame inspection threw', {
295
+ error: describe(error),
296
+ });
297
+ }
298
+ }, scoped),
299
+ await ctx.session.hook('retry', async (draft) => {
300
+ const rec = pickForRetry(draft.sessionID);
301
+ if (!rec)
302
+ return;
303
+ const hostDecision = draft.decision;
304
+ let decision = hostDecision;
305
+ let reason;
306
+ if (rec.accountId === undefined) {
307
+ decision = { retry: false };
308
+ reason = 'no-account';
309
+ }
310
+ else if (rec.outputStarted) {
311
+ // The user has already seen part of this answer; a retry would
312
+ // send it again.
313
+ decision = { retry: false };
314
+ reason = 'output-started';
315
+ }
316
+ else {
317
+ if (!rec.limit) {
318
+ const signal = adapter.limitFromError?.(draft.error);
319
+ if (signal)
320
+ noteLimit(rec, signal, 'error');
321
+ }
322
+ if (rec.limit) {
323
+ await rec.limit.delivered;
324
+ rec.rerouteFrom = {
325
+ accountId: rec.accountId,
326
+ limit: rec.limit.signal,
327
+ };
328
+ // No delay: the next attempt goes to another account, and the
329
+ // host would otherwise wait out the refused account's backoff,
330
+ // or not retry at all for errors it deems final.
331
+ decision = { retry: true, delay: 0 };
332
+ reason = 'reroute';
333
+ }
334
+ else {
335
+ reason = 'host-decides';
336
+ }
337
+ }
338
+ draft.decision = decision;
339
+ await emit('retry', {
340
+ sessionID: draft.sessionID,
341
+ ...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
342
+ kind: rec.scope.kind,
343
+ attempt: draft.attempt,
344
+ reason,
345
+ hostDecision,
346
+ decision,
347
+ });
348
+ }, scoped),
349
+ ];
350
+ const forgetSession = (sessionID) => {
351
+ for (const [key, rec] of records) {
352
+ if (rec.scope.sessionID === sessionID)
353
+ records.delete(key);
354
+ }
355
+ };
356
+ const abort = new AbortController();
357
+ const events = ctx.event;
358
+ if (events) {
359
+ void (async () => {
360
+ try {
361
+ for await (const event of events.subscribe({ signal: abort.signal })) {
362
+ if (event.type === 'session.deleted')
363
+ forgetSession(event.data.sessionID);
364
+ }
365
+ }
366
+ catch (error) {
367
+ if (!abort.signal.aborted) {
368
+ warn('opencode2 auth stopped listening for session deletion', {
369
+ error: describe(error),
370
+ });
371
+ }
372
+ }
373
+ })();
374
+ }
375
+ let disposed = false;
376
+ return {
377
+ on(event, listener) {
378
+ let set = listeners.get(event);
379
+ if (!set) {
380
+ set = new Set();
381
+ listeners.set(event, set);
382
+ }
383
+ const entry = listener;
384
+ set.add(entry);
385
+ return () => {
386
+ set.delete(entry);
387
+ };
388
+ },
389
+ accountFor(sessionID, kind) {
390
+ return records.get(keyOf(sessionID, kind))?.accountId;
391
+ },
392
+ forgetSession,
393
+ get size() {
394
+ return records.size;
395
+ },
396
+ async dispose() {
397
+ if (disposed)
398
+ return;
399
+ disposed = true;
400
+ abort.abort();
401
+ records.clear();
402
+ listeners.clear();
403
+ await Promise.all(registrations.map(async (registration) => {
404
+ try {
405
+ await registration.dispose();
406
+ }
407
+ catch (error) {
408
+ warn('opencode2 auth hook did not dispose cleanly', {
409
+ error: describe(error),
410
+ });
411
+ }
412
+ }));
413
+ },
414
+ };
415
+ }
@@ -0,0 +1,78 @@
1
+ import type { Credential, Plugin } from '@opencode/plugin';
2
+ import type { IntegrationOAuthMethod, IntegrationOAuthMethodRegistration } from '@opencode/plugin/promise/integration';
3
+ import type { Registration } from '@opencode/plugin/promise/registration';
4
+ /** The host's form answer handed to `authorize`. */
5
+ export type FormAnswer = Parameters<IntegrationOAuthMethodRegistration['authorize']>[0];
6
+ /**
7
+ * Prefix of every placeholder secret. No provider issues tokens of this
8
+ * shape, so a placeholder that escaped to the wire is refused by the
9
+ * provider and recognisable in any log.
10
+ */
11
+ export declare const PLACEHOLDER_PREFIX = "common-auth-placeholder";
12
+ /** How long a placeholder claims to be valid; refresh simply issues another. */
13
+ export declare const PLACEHOLDER_LIFETIME_MS: number;
14
+ /** Metadata key marking a host credential as a placeholder. */
15
+ export declare const PLACEHOLDER_METADATA_KEY = "commonAuthPlaceholder";
16
+ /** The non-routable secret the host holds for an integration. */
17
+ export declare function placeholderSecret(integrationID: string): string;
18
+ /**
19
+ * The credential the host stores for an integration whose real accounts live
20
+ * in the plugin's pool. It keeps the provider catalogued and usable in the
21
+ * host, and carries nothing that works against the provider.
22
+ */
23
+ export declare function placeholderCredential(input: {
24
+ integrationID: string;
25
+ methodID: string;
26
+ now: number;
27
+ }): Credential.OAuth;
28
+ /** Whether a host credential is a placeholder (for any integration, or the one named). */
29
+ export declare function isPlaceholderCredential(value: Credential.Value | undefined, integrationID?: string): boolean;
30
+ /**
31
+ * The plugin's half of an OAuth login. It has the same shape as the host's
32
+ * authorization, except that `callback` resolves to the plugin's own login
33
+ * result instead of a host credential.
34
+ */
35
+ export type PoolAuthorization<T> = {
36
+ readonly url: string;
37
+ readonly instructions: string;
38
+ readonly expiresAt?: number;
39
+ } & ({
40
+ readonly mode: 'auto';
41
+ readonly callback: Promise<T>;
42
+ } | {
43
+ readonly mode: 'code';
44
+ readonly callback: (code: string) => Promise<T>;
45
+ });
46
+ export interface PoolLoginMethod<T> {
47
+ readonly method: IntegrationOAuthMethod;
48
+ authorize(answer: FormAnswer): Promise<PoolAuthorization<T>>;
49
+ }
50
+ export interface RegisterOpenCode2AuthMethodsOptions<T> {
51
+ readonly integrationID: string;
52
+ readonly methods: readonly PoolLoginMethod<T>[];
53
+ /**
54
+ * Writes a completed login into the plugin's pool. The host only receives
55
+ * a placeholder once this resolves; if it throws, the login fails in the
56
+ * host and nothing is stored there.
57
+ */
58
+ onLogin(result: T, context: {
59
+ readonly integrationID: string;
60
+ readonly methodID: string;
61
+ }): Promise<void> | void;
62
+ /** Label the host shows for its (placeholder) connection. */
63
+ readonly label?: string;
64
+ readonly now?: () => number;
65
+ }
66
+ /**
67
+ * Registers the plugin's login methods with the host so that a login lands in
68
+ * the plugin's pool and the host keeps only a placeholder credential.
69
+ *
70
+ * Host-driven refresh never reaches the pool: it hands back a fresh
71
+ * placeholder whatever it is given. A real credential stored by an earlier
72
+ * login under one of these method IDs is therefore replaced by a placeholder
73
+ * at its first refresh; a plugin that wants to import such a login must read
74
+ * it (`ctx.integration.connection`) before that happens.
75
+ */
76
+ export declare function registerOpenCode2AuthMethods<T>(ctx: {
77
+ readonly integration: Pick<Plugin.Context['integration'], 'transform'>;
78
+ }, options: RegisterOpenCode2AuthMethodsOptions<T>): Promise<Registration>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Prefix of every placeholder secret. No provider issues tokens of this
3
+ * shape, so a placeholder that escaped to the wire is refused by the
4
+ * provider and recognisable in any log.
5
+ */
6
+ export const PLACEHOLDER_PREFIX = 'common-auth-placeholder';
7
+ /** How long a placeholder claims to be valid; refresh simply issues another. */
8
+ export const PLACEHOLDER_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
9
+ /** Metadata key marking a host credential as a placeholder. */
10
+ export const PLACEHOLDER_METADATA_KEY = 'commonAuthPlaceholder';
11
+ /** The non-routable secret the host holds for an integration. */
12
+ export function placeholderSecret(integrationID) {
13
+ return `${PLACEHOLDER_PREFIX}.${integrationID}`;
14
+ }
15
+ /**
16
+ * The credential the host stores for an integration whose real accounts live
17
+ * in the plugin's pool. It keeps the provider catalogued and usable in the
18
+ * host, and carries nothing that works against the provider.
19
+ */
20
+ export function placeholderCredential(input) {
21
+ const secret = placeholderSecret(input.integrationID);
22
+ return {
23
+ type: 'oauth',
24
+ methodID: input.methodID,
25
+ access: secret,
26
+ refresh: secret,
27
+ expires: input.now + PLACEHOLDER_LIFETIME_MS,
28
+ metadata: { [PLACEHOLDER_METADATA_KEY]: true },
29
+ };
30
+ }
31
+ /** Whether a host credential is a placeholder (for any integration, or the one named). */
32
+ export function isPlaceholderCredential(value, integrationID) {
33
+ if (value?.type !== 'oauth')
34
+ return false;
35
+ const expected = integrationID === undefined ? undefined : placeholderSecret(integrationID);
36
+ const matches = (secret) => expected === undefined
37
+ ? secret.startsWith(`${PLACEHOLDER_PREFIX}.`)
38
+ : secret === expected;
39
+ return matches(value.access) && matches(value.refresh);
40
+ }
41
+ /**
42
+ * Registers the plugin's login methods with the host so that a login lands in
43
+ * the plugin's pool and the host keeps only a placeholder credential.
44
+ *
45
+ * Host-driven refresh never reaches the pool: it hands back a fresh
46
+ * placeholder whatever it is given. A real credential stored by an earlier
47
+ * login under one of these method IDs is therefore replaced by a placeholder
48
+ * at its first refresh; a plugin that wants to import such a login must read
49
+ * it (`ctx.integration.connection`) before that happens.
50
+ */
51
+ export async function registerOpenCode2AuthMethods(ctx, options) {
52
+ const now = options.now ?? Date.now;
53
+ const { integrationID } = options;
54
+ const placeholderFor = (methodID) => placeholderCredential({ integrationID, methodID, now: now() });
55
+ const store = async (result, methodID) => {
56
+ await options.onLogin(result, { integrationID, methodID });
57
+ return placeholderFor(methodID);
58
+ };
59
+ const authorizeWith = (login) => async (answer) => {
60
+ const methodID = login.method.id;
61
+ const pending = await login.authorize(answer);
62
+ const common = {
63
+ url: pending.url,
64
+ instructions: pending.instructions,
65
+ ...(pending.expiresAt === undefined
66
+ ? {}
67
+ : { expiresAt: pending.expiresAt }),
68
+ };
69
+ if (pending.mode === 'auto') {
70
+ return {
71
+ ...common,
72
+ mode: 'auto',
73
+ callback: pending.callback.then((result) => store(result, methodID)),
74
+ };
75
+ }
76
+ const callback = pending.callback;
77
+ return {
78
+ ...common,
79
+ mode: 'code',
80
+ callback: async (code) => store(await callback(code), methodID),
81
+ };
82
+ };
83
+ return ctx.integration.transform((editor) => {
84
+ for (const login of options.methods) {
85
+ editor.method.update({
86
+ integrationID,
87
+ method: login.method,
88
+ authorize: authorizeWith(login),
89
+ refresh: async (credential) => placeholderFor(credential.methodID),
90
+ ...(options.label === undefined ? {} : { label: () => options.label }),
91
+ });
92
+ }
93
+ });
94
+ }
@@ -0,0 +1,12 @@
1
+ /** One server-sent event: its `event:` name, if any, and its joined `data:` lines. */
2
+ export interface ServerSentEvent {
3
+ readonly event?: string;
4
+ readonly data: string;
5
+ }
6
+ /**
7
+ * A pass-through stream that hands every complete server-sent event to
8
+ * `onEvent` while forwarding the original bytes unchanged, so the host still
9
+ * consumes the body exactly once. A throwing `onEvent` is reported to
10
+ * `onError` and never breaks the stream.
11
+ */
12
+ export declare function watchServerSentEvents(onEvent: (event: ServerSentEvent) => void, onError: (error: unknown) => void): TransformStream<Uint8Array, Uint8Array>;
@@ -0,0 +1,60 @@
1
+ function parseBlock(block) {
2
+ const data = [];
3
+ let event;
4
+ for (const line of block.split(/\r?\n/)) {
5
+ if (line.startsWith('data:'))
6
+ data.push(line.slice(5).replace(/^ /, ''));
7
+ else if (line.startsWith('event:'))
8
+ event = line.slice(6).trim();
9
+ }
10
+ if (data.length === 0)
11
+ return undefined;
12
+ return event === undefined
13
+ ? { data: data.join('\n') }
14
+ : { event, data: data.join('\n') };
15
+ }
16
+ /**
17
+ * A pass-through stream that hands every complete server-sent event to
18
+ * `onEvent` while forwarding the original bytes unchanged, so the host still
19
+ * consumes the body exactly once. A throwing `onEvent` is reported to
20
+ * `onError` and never breaks the stream.
21
+ */
22
+ export function watchServerSentEvents(onEvent, onError) {
23
+ const decoder = new TextDecoder();
24
+ let buffer = '';
25
+ const emit = (block) => {
26
+ const event = parseBlock(block);
27
+ if (!event)
28
+ return;
29
+ try {
30
+ onEvent(event);
31
+ }
32
+ catch (error) {
33
+ onError(error);
34
+ }
35
+ };
36
+ const drain = () => {
37
+ for (;;) {
38
+ const match = /\r?\n\r?\n/.exec(buffer);
39
+ if (!match)
40
+ return;
41
+ const block = buffer.slice(0, match.index);
42
+ buffer = buffer.slice(match.index + match[0].length);
43
+ emit(block);
44
+ }
45
+ };
46
+ return new TransformStream({
47
+ transform(chunk, controller) {
48
+ controller.enqueue(chunk);
49
+ buffer += decoder.decode(chunk, { stream: true });
50
+ drain();
51
+ },
52
+ flush() {
53
+ buffer += decoder.decode();
54
+ drain();
55
+ if (buffer.trim() !== '')
56
+ emit(buffer);
57
+ buffer = '';
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,202 @@
1
+ import type { Plugin } from '@opencode/plugin';
2
+ import type { SessionRequestKind, SessionRetryDecision } from '@opencode/plugin/promise/session';
3
+ /** The parts of the OpenCode 2 plugin context the installer uses. */
4
+ export type OpenCode2HookContext = {
5
+ readonly session: Pick<Plugin.Context['session'], 'hook'>;
6
+ /** Used only to forget per-session records when a session is deleted. */
7
+ readonly event?: Pick<Plugin.Context['event'], 'subscribe'>;
8
+ };
9
+ export type RequestKind = SessionRequestKind;
10
+ /** Which request a hook call belongs to, as the host reports it. */
11
+ export interface RequestScope {
12
+ readonly providerID: string;
13
+ readonly modelID: string;
14
+ readonly sessionID: string;
15
+ readonly agent: string;
16
+ readonly kind: RequestKind;
17
+ }
18
+ /** Where a reading or a refusal was seen. */
19
+ export type Transport = 'http' | 'ws';
20
+ /**
21
+ * A provider refusal that should move the request to another account: a rate
22
+ * limit, an exhausted usage window, or anything else the adapter decides is
23
+ * tied to the account rather than to the request.
24
+ */
25
+ export interface LimitSignal {
26
+ /** Short machine-readable reason, such as the provider's error code. */
27
+ readonly reason: string;
28
+ readonly status?: number;
29
+ /** How long the provider asked the account to wait, when it said. */
30
+ readonly retryAfterMs?: number;
31
+ }
32
+ /** What the adapter learned from one streamed event or WebSocket frame. */
33
+ export interface EventVerdict<Q> {
34
+ /** The user has seen output from this response: a retry would repeat it. */
35
+ readonly outputStarted?: boolean;
36
+ readonly quota?: Q;
37
+ readonly limit?: LimitSignal;
38
+ }
39
+ /**
40
+ * Header changes for one account. A string sets the header (replacing every
41
+ * spelling of its name); `null` removes it.
42
+ */
43
+ export type HeaderEdits = Readonly<Record<string, string | null>>;
44
+ export interface ChooseAccountInput extends RequestScope {
45
+ /** The account the previous attempt of this session and kind used. */
46
+ readonly previousAccountId?: string;
47
+ /**
48
+ * Set when the previous attempt was refused for a limit before any output
49
+ * and the retry hook asked the host to try again. The adapter should not
50
+ * pick `accountId` again unless it has nothing else.
51
+ */
52
+ readonly rerouteFrom?: {
53
+ readonly accountId: string;
54
+ readonly limit: LimitSignal;
55
+ };
56
+ }
57
+ export interface AccountRequest extends RequestScope {
58
+ readonly accountId: string;
59
+ }
60
+ /** A host-supplied error, as the retry hook reports it. */
61
+ export interface HostError {
62
+ readonly type: string;
63
+ readonly message: string;
64
+ readonly status?: number;
65
+ }
66
+ /**
67
+ * Everything provider-specific the installer needs. `Q` is the plugin's own
68
+ * quota reading type; the installer only carries it to the `quota` event.
69
+ */
70
+ export interface OpenCode2AuthAdapter<Q = unknown> {
71
+ /** Every hook is scoped to this provider; other providers are untouched. */
72
+ readonly providerID: string;
73
+ /**
74
+ * Picks the account for one model request. Called again for every retry,
75
+ * so a refused account can be skipped. Returning `undefined` stops the
76
+ * request with `OpenCode2AuthError` kind `no-account`.
77
+ */
78
+ chooseAccount(input: ChooseAccountInput): Promise<string | undefined> | string | undefined;
79
+ /**
80
+ * The credential and per-account headers for an account, applied in
81
+ * `model.request`, `http.request` and `experimental.ws.handshake`. The last
82
+ * two run after the host has applied its own credential, so these headers
83
+ * win on the wire.
84
+ */
85
+ accountHeaders(input: AccountRequest): Promise<HeaderEdits> | HeaderEdits;
86
+ /**
87
+ * Optional request rewrite (URL, body) before the account headers are
88
+ * applied. Return `undefined` to keep the request.
89
+ */
90
+ rewriteRequest?(input: AccountRequest & {
91
+ readonly request: Request;
92
+ }): Promise<Request | undefined> | Request | undefined;
93
+ /**
94
+ * Optional response rewrite (body stream, status). It receives the
95
+ * response after quota, limit and output detection have been attached, so
96
+ * detection always sees the provider's own events.
97
+ */
98
+ rewriteResponse?(input: AccountRequest & {
99
+ readonly request: Request;
100
+ readonly response: Response;
101
+ }): Promise<Response | undefined> | Response | undefined;
102
+ /** Optional WebSocket URL rewrite. Return `undefined` to keep the URL. */
103
+ rewriteHandshakeURL?(input: AccountRequest & {
104
+ readonly url: string;
105
+ }): string | undefined;
106
+ /** Quota carried in HTTP response headers. */
107
+ quotaFromHeaders?(headers: Headers, status: number): Q | undefined;
108
+ /**
109
+ * Recognises an account-level refusal from an HTTP response before its
110
+ * body is streamed. `body()` reads a copy, so the host still gets the body.
111
+ */
112
+ limitFromResponse?(input: {
113
+ readonly status: number;
114
+ readonly headers: Headers;
115
+ readonly body: () => Promise<string>;
116
+ }): Promise<LimitSignal | undefined> | LimitSignal | undefined;
117
+ /**
118
+ * Inspects one server-sent event (`data` payload) or one WebSocket frame.
119
+ * Detects output, quota and refusals inside the stream.
120
+ */
121
+ inspectEvent?(input: {
122
+ readonly transport: Transport;
123
+ readonly data: string;
124
+ readonly event?: string;
125
+ }): EventVerdict<Q> | undefined;
126
+ /**
127
+ * Recognises an account-level refusal from the error the host hands the
128
+ * retry hook, for refusals no other hook saw.
129
+ */
130
+ limitFromError?(error: HostError): LimitSignal | undefined;
131
+ }
132
+ export interface OpenCode2AuthLogger {
133
+ warn(message: string, data?: unknown): void;
134
+ }
135
+ export interface InstallOpenCode2AuthOptions {
136
+ /**
137
+ * Values that must never reach the wire, normally the placeholder the host
138
+ * holds as its credential. Defaults to the placeholder secret for the
139
+ * adapter's provider.
140
+ */
141
+ readonly hostCredentials?: readonly string[];
142
+ /** Most `sessionID:kind` records kept before the oldest is dropped. */
143
+ readonly maxRecords?: number;
144
+ readonly logger?: OpenCode2AuthLogger;
145
+ }
146
+ export type RetryReason = 'reroute' | 'output-started' | 'no-account' | 'host-decides';
147
+ /**
148
+ * The hook that picked an account. Normally `model.request`; a transport hook
149
+ * picks only when the host skipped `model.request` for that request, which a
150
+ * plugin may want to log as a host change.
151
+ */
152
+ export type SelectingHook = 'model.request' | 'http.request' | 'experimental.ws.handshake';
153
+ export interface OpenCode2AuthEvents<Q> {
154
+ /** An account was picked for a model request. */
155
+ readonly select: AccountRequest & {
156
+ readonly hook: SelectingHook;
157
+ readonly previousAccountId?: string;
158
+ readonly rerouteFrom?: ChooseAccountInput['rerouteFrom'];
159
+ };
160
+ /** A quota reading, attributed through this installer's own record. */
161
+ readonly quota: AccountRequest & {
162
+ readonly transport: Transport;
163
+ readonly status?: number;
164
+ readonly quota: Q;
165
+ };
166
+ /**
167
+ * An account-level refusal. The retry hook waits for every listener of
168
+ * this event before asking the host to retry, so a plugin that marks the
169
+ * account limited here has the mark in place when `chooseAccount` runs.
170
+ */
171
+ readonly limit: AccountRequest & {
172
+ readonly via: Transport | 'error';
173
+ readonly limit: LimitSignal;
174
+ readonly outputStarted: boolean;
175
+ };
176
+ /** The retry hook ran for this provider. */
177
+ readonly retry: {
178
+ readonly sessionID: string;
179
+ readonly accountId?: string;
180
+ readonly kind?: RequestKind;
181
+ readonly attempt: number;
182
+ readonly reason: RetryReason;
183
+ readonly hostDecision: SessionRetryDecision;
184
+ readonly decision: SessionRetryDecision;
185
+ };
186
+ }
187
+ export type OpenCode2AuthEventName = keyof OpenCode2AuthEvents<unknown>;
188
+ export interface OpenCode2AuthInstallation<Q> {
189
+ /**
190
+ * Listens to an event. Listener errors are logged and never reach the
191
+ * host. Returns a function that removes the listener.
192
+ */
193
+ on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q>[E]) => void | Promise<void>): () => void;
194
+ /** The account last chosen for a session and request kind. */
195
+ accountFor(sessionID: string, kind: RequestKind): string | undefined;
196
+ /** Drops every record of a session. Session deletion does this itself. */
197
+ forgetSession(sessionID: string): void;
198
+ /** Number of `sessionID:kind` records held. */
199
+ readonly size: number;
200
+ /** Removes every hook and stops listening for session deletion. */
201
+ dispose(): Promise<void>;
202
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {