@cortexkit/common-auth 0.4.0 → 0.4.2

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.
@@ -359,22 +359,42 @@ export function acceptVaultRoute(path, routeId) {
359
359
  };
360
360
  });
361
361
  }
362
- function holdsReceipt(row, receipt) {
362
+ /**
363
+ * What a receipt may do to a row's quota: `hold` (the row holds the receipt's
364
+ * credential and account), `bind` (the row has never had a known account and
365
+ * the receipt proves one, so the row takes that account with the reading) or
366
+ * `refuse`.
367
+ */
368
+ function receiptFence(row, receipt) {
363
369
  if (!memberIds(row).includes(receipt.credentialId))
364
- return false;
370
+ return 'refuse';
365
371
  // While the vault makes no claim, the roster's own expectation proves
366
372
  // nothing about which account the reading came from.
367
373
  if (row.unclaimed &&
368
374
  (receipt.accountIdentitySource === 'expected' ||
369
375
  receipt.accountIdentitySource === 'none'))
370
- return false;
376
+ return 'refuse';
371
377
  // Equality, never compatibility: a known account on one side does not match
372
- // an absent one on the other. The one exception is a row still without any
373
- // known account, reached by a receipt issued for it in that state; the
374
- // credential id above is then the whole binding.
375
- return (row.accountIdentity === receipt.accountIdentity ||
376
- (row.accountIdentity === undefined &&
377
- receipt.expectedAccountIdentity === undefined));
378
+ // an absent one on the other. The only absent-absent match is a row that
379
+ // has never had a known account and a receipt that names none either.
380
+ if (row.accountIdentity === receipt.accountIdentity)
381
+ return 'hold';
382
+ // A row that has never had a known account, reached by a receipt issued for
383
+ // it in that state whose served or parsed identity names an account. The
384
+ // reading belongs to that account, so keeping it without the account would
385
+ // let the next list that names a different account inherit it as if that
386
+ // account had been learned for the first time. The row takes the account
387
+ // with the reading instead: a later list naming another account is then a
388
+ // known replacement and inherits nothing. Only a single-credential row is
389
+ // bound, since one credential's account says nothing about the others.
390
+ if (row.accountIdentity === undefined &&
391
+ receipt.expectedAccountIdentity === undefined &&
392
+ receipt.accountIdentity !== undefined &&
393
+ (receipt.accountIdentitySource === 'asserted' ||
394
+ receipt.accountIdentitySource === 'parsed') &&
395
+ memberIds(row).length === 1)
396
+ return 'bind';
397
+ return 'refuse';
378
398
  }
379
399
  /**
380
400
  * Merge a quota observation into a vault row through `/quota`'s merge. The
@@ -386,13 +406,20 @@ function holdsReceipt(row, receipt) {
386
406
  export function recordVaultQuota(path, input) {
387
407
  return mutateVaultRoster(path, (current) => {
388
408
  const row = current?.rows.find((entry) => entry.routeId === input.routeId);
389
- if (!current || !row || !holdsReceipt(row, input))
409
+ const fence = !current || !row ? 'refuse' : receiptFence(row, input);
410
+ if (!current || !row || fence === 'refuse')
390
411
  return { result: false };
391
412
  const quota = mergeQuotaObservation(row.quota, input.observation);
413
+ // A bound account is `unclaimed`: the vault's list has not named it, and
414
+ // the next projection keeps it as the account this credential last
415
+ // logged into until the vault names one.
416
+ const bound = fence === 'bind'
417
+ ? { accountIdentity: input.accountIdentity, unclaimed: true }
418
+ : {};
392
419
  return {
393
420
  next: {
394
421
  ...current,
395
- rows: current.rows.map((entry) => entry === row ? { ...entry, quota } : entry),
422
+ rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
396
423
  },
397
424
  result: true,
398
425
  };
@@ -2,6 +2,7 @@ import { PoolOperationError } from './errors.js';
2
2
  import { toFailure, withTransaction } from './mutate.js';
3
3
  import { LockStack } from './refresh-lock.js';
4
4
  import { refusal, unknownRow } from './runtime.js';
5
+ import { isCredentialEpoch } from './schema.js';
5
6
  /**
6
7
  * Merges a quota observation into a row's stored map under the store locks,
7
8
  * after attribution passes. Needs no row lock, so it is permitted from
@@ -12,6 +13,8 @@ export async function recordQuota(rt, id, attribution, observation) {
12
13
  const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
13
14
  const progress = { writes: 0 };
14
15
  try {
16
+ if (!isCredentialEpoch(attribution?.credentialEpoch))
17
+ throw refusal('pull', id, 'invalid-input', 'the credential epoch the reading was issued for must be a positive safe integer');
15
18
  await withTransaction(ctx, locks, progress, { operation: 'pull', rowId: id }, async (tx) => {
16
19
  const row = tx.row(id);
17
20
  if (!row)
@@ -19,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
19
19
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
20
20
  * refusals and failures of the operation itself.
21
21
  */
22
- export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
22
+ export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
23
23
  /**
24
24
  * The single failure value of every store operation. `committed` is present
25
25
  * only when the operation had already written a credential to the state file
@@ -12,7 +12,7 @@ export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.j
12
12
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
13
13
  export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
14
14
  export type { PullReason } from './runtime.js';
15
- export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
15
+ export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
16
16
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
17
17
  export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
18
18
  export { POOL_OWNED_KEYS } from './settings.js';
@@ -64,7 +64,10 @@ export declare class Transaction {
64
64
  readonly snapshot: Snapshot;
65
65
  private readonly locks;
66
66
  private readonly progress;
67
- private readonly info;
67
+ readonly info: {
68
+ operation: PoolOperation;
69
+ rowId: string | undefined;
70
+ };
68
71
  config: Record<string, unknown>;
69
72
  state: Record<string, unknown>;
70
73
  constructor(ctx: StoreContext, snapshot: Snapshot, locks: LockStack, progress: Progress, info: {
@@ -6,7 +6,7 @@ import { type PullHook } from './pull.js';
6
6
  import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
7
7
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
8
8
  import { type AddInput, type AddResult, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
- import { type PoolCredential, type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
9
+ import { type PoolCredential, type PoolRow, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
10
10
  import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
11
11
  export interface OpenPoolStoreOptions {
12
12
  /** The provider every row of this pool belongs to; keys the provider-wide lock. */
@@ -75,7 +75,13 @@ export interface PoolStore {
75
75
  credential: StoredCredential;
76
76
  credentialEpoch: number;
77
77
  }>;
78
- rotate(id: string, credential: PoolCredential, input?: {
78
+ /**
79
+ * Refreshes the secret a row holds without changing its account or
80
+ * endpoint. Since 0.4.1 an API key may leave out `baseURL` and `authHeader`
81
+ * to keep the row's, and one that gives another is refused
82
+ * (`endpoint-mismatch`) before writing: that is a `replace`.
83
+ */
84
+ rotate(id: string, credential: RotateCredential, input?: {
79
85
  identity?: string;
80
86
  }, options?: RowOperationOptions): Promise<{
81
87
  id: string;
@@ -3,7 +3,7 @@ import { PoolOperationError } from './errors.js';
3
3
  import { type Transaction } from './mutate.js';
4
4
  import type { PoolLockSpec } from './refresh-lock.js';
5
5
  import { type StoreRuntime } from './runtime.js';
6
- import { type CredentialBinding, type PoolCredential, type PoolRow, type StoredCredential } from './schema.js';
6
+ import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
7
7
  export type FailureHook = (rowId: string, error: PoolOperationError) => void | Promise<void>;
8
8
  export interface RowOperationOptions {
9
9
  /** Called once, awaited, on every non-success path, before locks release. */
@@ -91,9 +91,10 @@ export type AddResult = {
91
91
  * Writes a credential into the state file (one write), stamped with the
92
92
  * credential epoch the row's entry holds in `tx` (1 without an entry) and,
93
93
  * for a replace, the binding the config is about to get. A rotation is the
94
- * same lineage: no epoch bump, no identity or quota change.
94
+ * same lineage: no epoch bump, no identity or quota change. An API key must
95
+ * belong to the endpoint the row holds in `tx` (see `onRowEndpoint`).
95
96
  */
96
- export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, credential: PoolCredential, extra?: {
97
+ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, given: RotateCredential, extra?: {
97
98
  stamp?: number;
98
99
  clearErrors?: boolean;
99
100
  binding?: CredentialBinding;
@@ -106,7 +107,7 @@ export declare function replaceRow(rt: StoreRuntime, id: string, credential: Poo
106
107
  credential: StoredCredential;
107
108
  credentialEpoch: number;
108
109
  }>;
109
- export declare function rotateRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
110
+ export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: {
110
111
  identity?: string;
111
112
  }, options?: RowOperationOptions): Promise<{
112
113
  id: string;
@@ -3,7 +3,7 @@ import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
3
  import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
4
4
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
5
5
  import { readRow, refusal, rowLockSpec, unknownRow, } from './runtime.js';
6
- import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
6
+ import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
7
7
  import { bindReplacement } from './torn.js';
8
8
  /** Fields of a state entry that belong to the credential it replaces. */
9
9
  const CREDENTIAL_STATE_FIELDS = [
@@ -13,13 +13,52 @@ const CREDENTIAL_STATE_FIELDS = [
13
13
  'lastRefreshedAt',
14
14
  'apiKey',
15
15
  ];
16
+ /**
17
+ * The endpoint an API-key roster row sends its key to, as `buildRawRows` loads
18
+ * it: the trimmed `baseURL`, and a bearer header unless the row names
19
+ * `x-api-key`.
20
+ */
21
+ function rowEndpoint(raw) {
22
+ return {
23
+ baseURL: String(raw.baseURL).trim(),
24
+ authHeader: raw.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer',
25
+ };
26
+ }
27
+ /**
28
+ * The credential as it will sit in the row. The state file holds only an API
29
+ * key; its endpoint lives in the roster row. So an API key written into a row
30
+ * is sent wherever that row says, and one given for another `baseURL` or
31
+ * `authHeader` would silently be paired with an endpoint it was not issued
32
+ * for. A part the caller leaves out is the row's; a part it gives must equal
33
+ * the row's, else the write is refused (`endpoint-mismatch`) before anything
34
+ * is written. Moving a row to another endpoint is a `replace`, which writes
35
+ * the new endpoint first.
36
+ */
37
+ function onRowEndpoint(tx, id, credential) {
38
+ if (credential.type !== 'api')
39
+ return credential;
40
+ const raw = tx.rosterRow(id);
41
+ if (!isRecord(raw)) {
42
+ if (credential.baseURL === undefined)
43
+ throw refusal(tx.info.operation, id, 'invalid-input', 'api credential needs a valid baseURL');
44
+ return { ...credential, baseURL: credential.baseURL };
45
+ }
46
+ const endpoint = rowEndpoint(raw);
47
+ const baseURL = credential.baseURL?.trim() ?? endpoint.baseURL;
48
+ const authHeader = credential.authHeader ?? endpoint.authHeader;
49
+ if (baseURL !== endpoint.baseURL || authHeader !== endpoint.authHeader)
50
+ throw refusal(tx.info.operation, id, 'endpoint-mismatch', `row ${id} sends its key to another endpoint or header; a key for another endpoint is a replacement`);
51
+ return { ...credential, baseURL, authHeader };
52
+ }
16
53
  /**
17
54
  * Writes a credential into the state file (one write), stamped with the
18
55
  * credential epoch the row's entry holds in `tx` (1 without an entry) and,
19
56
  * for a replace, the binding the config is about to get. A rotation is the
20
- * same lineage: no epoch bump, no identity or quota change.
57
+ * same lineage: no epoch bump, no identity or quota change. An API key must
58
+ * belong to the endpoint the row holds in `tx` (see `onRowEndpoint`).
21
59
  */
22
- export async function rotateIn(rt, tx, id, credential, extra = {}) {
60
+ export async function rotateIn(rt, tx, id, given, extra = {}) {
61
+ const credential = onRowEndpoint(tx, id, given);
23
62
  const prior = tx.stateAccount(id);
24
63
  const priorStamp = typeof prior?.lastRefreshedAt === 'number'
25
64
  ? prior.lastRefreshedAt
@@ -35,17 +74,7 @@ export async function rotateIn(rt, tx, id, credential, extra = {}) {
35
74
  delete kept.lastQuotaRefreshError;
36
75
  delete kept.quota;
37
76
  }
38
- const raw = tx.rosterRow(id);
39
- const stored = credential.type === 'api' && isRecord(raw)
40
- ? storedCredential({
41
- ...credential,
42
- baseURL: String(raw.baseURL ?? credential.baseURL),
43
- ...(raw.authHeader === 'x-api-key' ||
44
- raw.authHeader === 'authorization-bearer'
45
- ? { authHeader: raw.authHeader }
46
- : {}),
47
- }, stamp)
48
- : storedCredential(credential, stamp);
77
+ const stored = storedCredential(credential, stamp);
49
78
  const epoch = tx.entry(id)?.credentialEpoch;
50
79
  tx.setStateAccount(id, {
51
80
  ...kept,
@@ -59,7 +88,9 @@ function checkInput(operation, id, credential) {
59
88
  const idIssue = operation === 'add' ? idProblem(id) : undefined;
60
89
  if (idIssue)
61
90
  throw refusal(operation, id, 'invalid-input', idIssue);
62
- const credentialIssue = credentialProblem(credential);
91
+ const credentialIssue = credentialProblem(credential, {
92
+ baseURLOptional: operation === 'rotate',
93
+ });
63
94
  if (credentialIssue)
64
95
  throw refusal(operation, id, 'invalid-input', credentialIssue);
65
96
  }
@@ -98,6 +129,14 @@ export async function addRow(rt, input, options = {}) {
98
129
  const fingerprint = fingerprintOf(credential);
99
130
  const same = rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
100
131
  if (same) {
132
+ // The same secret is the same credential, so re-adding it rotates
133
+ // that row. An identity or endpoint given with it must match the
134
+ // row's; a different one is refused rather than silently replaced
135
+ // by what the row already holds.
136
+ if (identity !== undefined &&
137
+ same.identity !== undefined &&
138
+ identity !== same.identity)
139
+ throw identityMismatch('add', same.id);
101
140
  const stored = await rotateIn(rt, tx, same.id, credential);
102
141
  return { id: same.id, outcome: 'rotated', credential: stored };
103
142
  }
@@ -109,6 +148,10 @@ export async function addRow(rt, input, options = {}) {
109
148
  throw refusal('add', id, 'id-exists', `row ${id} already holds a credential`);
110
149
  if (existing.type !== credential.type)
111
150
  throw refusal('add', id, 'type-mismatch', `row ${id} is a ${existing.type} row`);
151
+ if (identity !== undefined &&
152
+ existing.identity !== undefined &&
153
+ identity !== existing.identity)
154
+ throw identityMismatch('add', id);
112
155
  // A roster row without a credential (left by another writer, or
113
156
  // by an add of an earlier version that stopped between its
114
157
  // writes): writing the credential now completes it at epoch 1.
@@ -180,6 +223,11 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
180
223
  throw keyChanged('replace', id);
181
224
  const priorEpoch = tx.entry(id)?.credentialEpoch;
182
225
  const credentialEpoch = (typeof priorEpoch === 'number' ? priorEpoch : 1) + 1;
226
+ // An epoch past the safe integer range could equal the one before
227
+ // it, so readers could not tell the new credential from the old.
228
+ // Refused before anything is written; the row keeps its credential.
229
+ if (!isCredentialEpoch(credentialEpoch))
230
+ throw refusal('replace', id, 'invalid-row', `row ${id}'s credential epoch cannot advance past ${Number.MAX_SAFE_INTEGER}; remove the row and add the new credential as a new row`);
183
231
  const binding = {
184
232
  ...(input.identity !== undefined
185
233
  ? { identity: input.identity }
@@ -494,9 +542,7 @@ export async function recordRowIdentity(rt, id, identity, attribution, options =
494
542
  const captured = isRecord(attribution)
495
543
  ? attribution.credentialEpoch
496
544
  : undefined;
497
- if (typeof captured !== 'number' ||
498
- !Number.isInteger(captured) ||
499
- captured < 1)
545
+ if (!isCredentialEpoch(captured))
500
546
  throw refusal('recordIdentity', id, 'invalid-input', 'the credential epoch the identity lookup was issued for is required');
501
547
  const { row: seen } = await readRow(rt, 'recordIdentity', id);
502
548
  await locks.acquire(rowLockSpec(rt, seen));
@@ -25,6 +25,14 @@ export type ApiKeyCredential = {
25
25
  authHeader?: 'authorization-bearer' | 'x-api-key';
26
26
  };
27
27
  export type PoolCredential = OAuthCredential | ApiKeyCredential;
28
+ /**
29
+ * What `rotate` takes. Rotation refreshes the secret of the endpoint a row
30
+ * already has, so an API key may leave out `baseURL` (and `authHeader`) to
31
+ * keep the row's; one it gives must equal the row's.
32
+ */
33
+ export type RotateCredential = OAuthCredential | (Omit<ApiKeyCredential, 'baseURL'> & {
34
+ baseURL?: string;
35
+ });
28
36
  /** A credential as stored: an OAuth credential also carries its refresh stamp. */
29
37
  export type StoredCredential = (OAuthCredential & {
30
38
  lastRefreshedAt?: number;
@@ -130,6 +138,12 @@ export declare function fingerprintOf(credential: PoolCredential | StoredCredent
130
138
  */
131
139
  export declare function credentialDigest(credential: PoolCredential | StoredCredential): string;
132
140
  export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding?: CredentialBinding): CredentialStamp;
141
+ /**
142
+ * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
143
+ * adding one may give back the same number, so a replace would not move the
144
+ * epoch and nothing could tell the old credential's work from the new one's.
145
+ */
146
+ export declare function isCredentialEpoch(value: unknown): value is number;
133
147
  /** A well-formed stamp, or undefined for anything else (which is ignored). */
134
148
  export declare function parseStamp(raw: unknown): CredentialStamp | undefined;
135
149
  /** A parsed file, or the reason it could not be parsed. */
@@ -183,4 +197,6 @@ export declare function rosterRowFor(input: {
183
197
  /** The state-file fields a credential occupies, in the older readers' names. */
184
198
  export declare function stateFieldsFor(credential: PoolCredential, lastRefreshedAt: number | undefined): Record<string, unknown>;
185
199
  export declare function storedCredential(credential: PoolCredential, lastRefreshedAt: number | undefined): StoredCredential;
186
- export declare function credentialProblem(credential: unknown): string | undefined;
200
+ export declare function credentialProblem(credential: unknown, options?: {
201
+ baseURLOptional?: boolean;
202
+ }): string | undefined;
@@ -76,12 +76,20 @@ export function stampFor(credential, credentialEpoch, binding) {
76
76
  ...(binding ? { binding: { ...binding } } : {}),
77
77
  };
78
78
  }
79
+ /**
80
+ * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
81
+ * adding one may give back the same number, so a replace would not move the
82
+ * epoch and nothing could tell the old credential's work from the new one's.
83
+ */
84
+ export function isCredentialEpoch(value) {
85
+ return Number.isSafeInteger(value) && value >= 1;
86
+ }
79
87
  /** A well-formed stamp, or undefined for anything else (which is ignored). */
80
88
  export function parseStamp(raw) {
81
89
  if (!isRecord(raw))
82
90
  return undefined;
83
91
  const epoch = raw.credentialEpoch;
84
- if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 1)
92
+ if (!isCredentialEpoch(epoch))
85
93
  return undefined;
86
94
  if (typeof raw.digest !== 'string')
87
95
  return undefined;
@@ -190,7 +198,7 @@ function parseEntry(raw, codec) {
190
198
  if (!isRecord(raw))
191
199
  return undefined;
192
200
  const epoch = raw.credentialEpoch;
193
- if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 1)
201
+ if (!isCredentialEpoch(epoch))
194
202
  return undefined;
195
203
  if ('needsFirstReading' in raw && typeof raw.needsFirstReading !== 'boolean')
196
204
  return undefined;
@@ -395,7 +403,7 @@ export function storedCredential(credential, lastRefreshedAt) {
395
403
  ...(lastRefreshedAt !== undefined ? { lastRefreshedAt } : {}),
396
404
  };
397
405
  }
398
- export function credentialProblem(credential) {
406
+ export function credentialProblem(credential, options = {}) {
399
407
  if (!isRecord(credential))
400
408
  return 'credential must be an object';
401
409
  if (credential.type === 'oauth') {
@@ -408,7 +416,8 @@ export function credentialProblem(credential) {
408
416
  return 'api credential needs an api key';
409
417
  if (credential.apiKey.trim() !== credential.apiKey)
410
418
  return 'api key must not carry surrounding whitespace';
411
- if (!isValidBaseURL(credential.baseURL))
419
+ const omitted = options.baseURLOptional && credential.baseURL === undefined;
420
+ if (!omitted && !isValidBaseURL(credential.baseURL))
412
421
  return 'api credential needs a valid baseURL';
413
422
  return undefined;
414
423
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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": {