diff --git a/.changeset/27919-borrowed-credential-verdict-retracts.md b/.changeset/27919-borrowed-credential-verdict-retracts.md new file mode 100644 index 00000000000..14c24cd0f51 --- /dev/null +++ b/.changeset/27919-borrowed-credential-verdict-retracts.md @@ -0,0 +1,14 @@ +--- +'@objectstack/types': patch +'@objectstack/service-automation': patch +'@objectstack/service-datasource': patch +--- + +fix: a plugin-driver datasource refused at boot for its connector instance's credential recovers on the next granted borrow, and the connector call path withholds the form-encoded spelling of a credential + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`@objectstack/service-datasource`**: a datasource served by a plugin driver (driver-odata) that names `external.connector`, whose instance could not lend at boot (undeclared, disabled, no `auth`), was recorded `failed-credentials` and marked unavailable until a restart, although the driver borrows again on every request and served as soon as the instance was repaired. The first borrow the lender grants for that instance now retracts the verdict and the engine's unavailable mark, so Setup → Datasources shows it available again with no restart. A grant for any other instance leaves it standing. The degraded-boot banner says which recovery applies; with no lender registered the verdict still lasts until a restart. +- **`@objectstack/types`**: `IConnectorCredentialSource` gains an optional `onLent(listener)`, called with the instance's name on every granted borrow and returning the unsubscribe. A source without it keeps working; nothing then retracts such a verdict. +- **`@objectstack/service-automation`**: the connector credential source implements `onLent`. The connector call path's `credentialForms` now takes its list from `borrowedCredentialForms`, so an upstream that echoes an API key in a form-encoded query string (a space written `+`) has that spelling withheld from the degraded reason, the stored status and the logs, as a datasource borrowing the same credential already had. diff --git a/packages/services/service-automation/src/connector-credential-door.test.ts b/packages/services/service-automation/src/connector-credential-door.test.ts index 1d5a2ca5da2..f8b0a4f6fad 100644 --- a/packages/services/service-automation/src/connector-credential-door.test.ts +++ b/packages/services/service-automation/src/connector-credential-door.test.ts @@ -682,4 +682,43 @@ describe('[#27237] contract review: an encoded spelling of the value is withheld } } }); + + // [ADR-0015 D7] The connector path takes its spellings from the shared + // `borrowedCredentialForms`, so the form encoding `URLSearchParams` writes + // (a space as `+`, which `encodeURIComponent` spells `%20`) is withheld here + // as it is for a datasource borrowing the same credential. + it('an api-key instance whose upstream echoes the form-encoded query string: no spelling reaches the reason, the row or a log', async () => { + const formEncoded = new URLSearchParams({ v: ENCODABLE }).toString().slice(2); + expect(formEncoded).not.toBe(encodeURIComponent(ENCODABLE)); + expect(credentialForms(ENCODABLE)).toContain(formEncoded); + + const out = captureOutput(); + const provider = fakeProvider(); + const factory: ConnectorProviderFactory = (ctx) => { + provider.state.materialized.push(ctx); + const key = ctx.auth?.type === 'api-key' ? ctx.auth.key : ''; + const echo = `GET https://erp.example/orders?${new URLSearchParams({ api_key: key })} was refused`; + return { + def: { name: ctx.name, label: ctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] } as unknown as Connector, + handlers: { request: async () => ({ status: 401, ok: false, body: { message: echo } }) }, + }; + }; + const erp = { ...billing('erp'), auth: { type: 'api-key', paramName: 'api_key', credentialRef: REF } }; + const { engine, call } = await boot([erp], { provider: { ...provider, factory } }); + + await engine.setConnectorCredential({ ref: REF, value: ENCODABLE, context: ADMIN }); + expect(await call('erp')).toMatchObject({ status: 401 }); + const reason = engine.getConnectorDegradedReason('erp') ?? ''; + expect(reason).toContain('[credential withheld]'); + + for (const [where, text] of [ + ['reason on a call', reason], + ['listing after the call', JSON.stringify(await engine.listConnectorCredentialRefs())], + ['logs', out.text()], + ] as const) { + for (const form of [formEncoded, encodeURIComponent(ENCODABLE), ENCODABLE]) { + expect(text.includes(form), `${where}: a spelling of the value`).toBe(false); + } + } + }); }); diff --git a/packages/services/service-automation/src/connector-credential-source.test.ts b/packages/services/service-automation/src/connector-credential-source.test.ts index b63d1e9bec6..7d43276c307 100644 --- a/packages/services/service-automation/src/connector-credential-source.test.ts +++ b/packages/services/service-automation/src/connector-credential-source.test.ts @@ -259,3 +259,42 @@ describe('[ADR-0015 D7] createConnectorCredentialSource — the refusals of a ha expect(reads).toBe(1); }); }); + +describe('[ADR-0015 D7] createConnectorCredentialSource — onLent announces each granted borrow', () => { + const entry = sap(); + const source = (readDeclared: () => Promise) => createConnectorCredentialSource({ + readDeclared, + chain: async () => ({ env: () => DUMMY_VALUE }), + vaultWired: () => false, + generation: () => 0, + }); + + it('names the instance on a lend, never on a refusal, and stops after the unsubscribe', async () => { + let declared: unknown[] = [{ ...entry, enabled: false }]; + const lender = source(async () => declared); + const lent: string[] = []; + const unsubscribe = lender.onLent!((connector) => lent.push(connector)); + + await expect(lender.borrow('sap_s4')).rejects.toMatchObject({ status: 422 }); + await expect(lender.borrow('not_declared')).rejects.toMatchObject({ status: 422 }); + expect(lent).toEqual([]); + + declared = [entry]; + await lender.borrow('sap_s4'); + expect(lent).toEqual(['sap_s4']); + + unsubscribe(); + await lender.borrow('sap_s4'); + expect(lent).toEqual(['sap_s4']); + }); + + it('grants the borrow when a listener throws', async () => { + const lender = source(async () => [entry]); + const later: string[] = []; + lender.onLent!(() => { throw new Error('listener fault'); }); + lender.onLent!((connector) => later.push(connector)); + const handle = await lender.borrow('sap_s4'); + expect(handle.connector).toBe('sap_s4'); + expect(later).toEqual(['sap_s4']); + }); +}); diff --git a/packages/services/service-automation/src/connector-credential-source.ts b/packages/services/service-automation/src/connector-credential-source.ts index 82836b2f091..39517b84cd6 100644 --- a/packages/services/service-automation/src/connector-credential-source.ts +++ b/packages/services/service-automation/src/connector-credential-source.ts @@ -29,6 +29,11 @@ * path runs, or on a `refresh()`. The grant-bearing kinds ADR-0143 adds answer * through the same handle, per dispatch, when they land. * + * Each granted borrow is announced to the source's `onLent` subscribers by + * the instance's name alone, so a datasource the boot recorded as unable to + * borrow is re-judged the moment its instance lends again (the datasource + * connection service subscribes). + * * ⛔ A value leaves this module only as the return of a handle's `get` / * `refresh`. Every refusal names the instance and the reference, never a * value, and a host resolver's own message is not interpolated. @@ -195,10 +200,27 @@ export function createConnectorCredentialSource(deps: ConnectorCredentialSourceD return item as Lendable; }; + const lentListeners = new Set<(connector: string) => void>(); + /** Tell every `onLent` subscriber; a listener's throw stays its own. */ + const announceLent = (connector: string): void => { + for (const listener of [...lentListeners]) { + try { + listener(connector); + } catch { + /* the borrow was granted either way */ + } + } + }; + return { + onLent(listener: (connector: string) => void): () => void { + lentListeners.add(listener); + return () => void lentListeners.delete(listener); + }, async borrow(connector: string): Promise { const refusal = lendRefusal(connector, findDeclared(await readDeclared(connector), connector)); if (refusal) throw connectorCredentialRefusal(refusal, 'VALIDATION_ERROR', 422); + announceLent(connector); let held: { generation: number; credential: BorrowedConnectorCredential } | undefined; let inflight: Promise | undefined; diff --git a/packages/services/service-automation/src/connector-credentials.ts b/packages/services/service-automation/src/connector-credentials.ts index f536fcd8706..de9adff2cb9 100644 --- a/packages/services/service-automation/src/connector-credentials.ts +++ b/packages/services/service-automation/src/connector-credentials.ts @@ -27,6 +27,7 @@ * a status and a revision, never the value. */ +import { borrowedCredentialForms } from '@objectstack/types'; import { defaultEnvCredentialResolver, type CredentialResolver } from './credential-resolver.js'; /** The object a connector credential lives in (`sys-connector-credential.object.ts`). */ @@ -390,27 +391,22 @@ export type WithheldCredential = string | readonly string[] | undefined; /** The shortest spelling a scrub withholds — a shorter one would blank ordinary words. */ const MIN_WITHHELD_LENGTH = 4; -function base64Of(text: string): string { - const bytes = new TextEncoder().encode(text); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - /** - * Every spelling of `value` an upstream or a client library may echo back: - * the value itself; URL-encoded, as an api-key sent as a query parameter - * travels in the request URL; base64; and, given the `basic` `username`, - * base64(`username:value`), the `Authorization: Basic` header. A base64 form - * is kept without its `=` padding, so an echo that drops the padding is - * withheld too. Longest first, so a longer form is replaced before a shorter - * one inside it. + * Every spelling of `value` an upstream or a client library may echo back — + * the one rule `borrowedCredentialForms` (`@objectstack/types`) holds for a + * datasource borrowing this credential, so the connector's own call path and + * the borrowing driver withhold the same list: the value itself; URL-encoded + * both ways (`encodeURIComponent`, and the form encoding `URLSearchParams` + * writes), as an api-key sent as a query parameter travels in the request + * URL; base64; and, given the `basic` `username`, base64(`username:value`), + * the `Authorization: Basic` header. Base64 forms are unpadded and the list + * is longest first. */ export function credentialForms(value: string | undefined, username?: string): string[] { if (!value) return []; - const forms = new Set([value, encodeURIComponent(value), base64Of(value).replace(/=+$/, '')]); - if (username !== undefined) forms.add(base64Of(`${username}:${value}`).replace(/=+$/, '')); - return [...forms].filter((f) => f.length >= MIN_WITHHELD_LENGTH).sort((a, b) => b.length - a.length); + return borrowedCredentialForms( + username !== undefined ? { type: 'basic', username, password: value } : { type: 'bearer', token: value }, + ); } function withheldForms(secret: WithheldCredential): readonly string[] { diff --git a/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts b/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts index 89d58035126..51b0b1e0280 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, @@ -393,6 +393,137 @@ describe('[ADR-0015 D7] a plugin driver adopted from its driver. service i }); }); +/** + * A lender double whose declaration can be repaired, with `onLent`, as the + * automation service's source has it: a borrow of a declared, enabled + * instance is granted and announced to the subscribers. + */ +function repairableLender(disabled: Set) { + const listeners = new Set<(connector: string) => void>(); + const source: IConnectorCredentialSource = { + async borrow(connector) { + if (disabled.has(connector)) { + throw connectorCredentialRefusal( + `connector instance '${connector}' (named by this datasource's external.connector) is disabled (enabled: false).`, + 'VALIDATION_ERROR', + 422, + ); + } + for (const listener of listeners) listener(connector); + const fixed: BorrowedConnectorCredential = { auth: { type: 'bearer', token: 'static-key' } }; + return { connector, get: async () => fixed, refresh: async () => fixed }; + }, + onLent(listener) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + }; + return { source, listeners }; +} + +describe('[ADR-0015 D7] an adopted plugin driver\'s failed-credentials verdict clears on the next granted borrow', () => { + const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; + let saved: string | undefined; + beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV]; }); + afterEach(() => { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + }); + + it('retracts the verdict and the unavailable mark once the repaired instance lends — and only for that instance', async () => { + const disabled = new Set(['sap_s4']); + const { source } = repairableLender(disabled); + const { ql, connection } = await adoptedPluginDriver(source); + const result = await connection.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } }); + expect(result.status).toBe('failed-credentials'); + expect(ql.listUnavailableDatasources()).toEqual([expect.objectContaining({ name: 'sap_orders' })]); + + // A grant for another instance proves nothing about this one. + await source.borrow('salesforce_crm'); + expect(connection.getConnectionState('sap_orders')).toMatchObject({ status: 'failed-credentials', availability: 'failed' }); + expect(ql.listUnavailableDatasources()).toHaveLength(1); + + // The instance is repaired, and the driver's next request borrows it. + disabled.delete('sap_s4'); + await source.borrow('sap_s4'); + expect(connection.getConnectionState('sap_orders')).toMatchObject({ status: 'already-registered', availability: 'available' }); + expect(connection.getConnectionState('sap_orders')?.reason).toBeUndefined(); + expect(ql.listUnavailableDatasources()).toEqual([]); + expect(ql.getDriverByName('sap_orders')).toBeDefined(); + }); + + it('subscribes once per lender, however many datasources wait on it', async () => { + const { source, listeners } = repairableLender(new Set(['sap_s4'])); + const { svc, engine } = service({ supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory, source); + for (const name of ['sap_orders', 'sap_invoices']) { + engine.drivers.set(name, { name } as IDataDriver); + await svc.connect({ ...sapOrders, name }, { context: { origin: 'code', trigger: 'declared-auto' } }); + } + expect(listeners.size).toBe(1); + await svc.disconnectAll(); + expect(listeners.size).toBe(0); + }); + + it('leaves a verdict standing that a disconnect or a later connect replaced', async () => { + const disabled = new Set(['sap_s4']); + const { source } = repairableLender(disabled); + const { svc, engine } = service({ supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory, source); + engine.drivers.set('sap_orders', { name: 'sap_orders' } as IDataDriver); + await svc.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } }); + await svc.disconnect('sap_orders'); + disabled.delete('sap_s4'); + await source.borrow('sap_s4'); + // Nothing resurrected for a datasource that was removed. + expect(svc.getConnectionState('sap_orders')).toBeUndefined(); + }); + + it('leaves the verdict when the driver was evicted since, rather than reporting a driver that is gone', async () => { + const disabled = new Set(['sap_s4']); + const { source } = repairableLender(disabled); + const { svc, engine } = service({ supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory, source); + engine.drivers.set('sap_orders', { name: 'sap_orders' } as IDataDriver); + await svc.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } }); + engine.drivers.delete('sap_orders'); + disabled.delete('sap_s4'); + await source.borrow('sap_s4'); + expect(svc.getConnectionState('sap_orders')?.status).toBe('failed-credentials'); + }); + + it('says in the degraded-boot banner that the next granted borrow clears it, and a restart where nothing can', async () => { + process.env[ENV] = '1'; + const banners = async (source: IConnectorCredentialSource) => { + const warnings: string[] = []; + const engine = fakeEngine(); + engine.drivers.set('sap_orders', { name: 'sap_orders' } as IDataDriver); + const svc = new DatasourceConnectionService({ + factory: () => ({ supports: () => false, create: vi.fn() }) as unknown as IDatasourceDriverFactory, + engine: () => engine, + connectorCredentials: () => source, + logger: { info() {}, debug() {}, error() {}, warn: (m: string) => void warnings.push(m) } as never, + }); + const realWrite = process.stderr.write; + (process.stderr as { write: unknown }).write = () => true; + try { + const result = await svc.connect(sapOrders, { objects: ['sap_order'], context: { origin: 'code', trigger: 'declared-auto' } }); + expect(result.status).toBe('failed-credentials'); + } finally { + (process.stderr as { write: unknown }).write = realWrite; + } + return warnings.join('\n'); + }; + + const retractable = await banners(repairableLender(new Set(['sap_s4'])).source); + expect(retractable).toContain('DEGRADED BOOT'); + expect(retractable).toContain("the first borrow granted for 'sap_s4' clears this verdict"); + expect(retractable).not.toContain('server is restarted'); + + // A lender without onLent: nothing can retract the verdict, and the banner keeps the restart. + const { onLent: _omitted, ...withoutOnLent } = repairableLender(new Set(['sap_s4'])).source; + const standing = await banners(withoutOnLent); + expect(standing).toContain('until it is reachable AND the server is restarted'); + }); +}); + describe('[ADR-0015 D7] borrowConnectorCredential', () => { it('never throws: a source that throws a non-Error comes back as a reason', async () => { const source = { borrow: async () => { throw 'boom'; } } as unknown as IConnectorCredentialSource; diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index 6780db063ff..152816f3eb1 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -333,6 +333,21 @@ export class DatasourceConnectionService { */ private readonly states = new Map(); + /** + * [ADR-0015 D7] The `failed-credentials` verdicts the already-registered + * guard recorded for an adopted plugin driver, by datasource name, each + * with the connector instance the datasource names and the reason recorded. + * The driver stays registered and borrows again on every request, so the + * verdict judges the declaration at boot, not for the life of the process: + * the first borrow the lender grants for THAT instance retracts it + * ({@link retractOnLend}). A connect, reconnect or disconnect of the + * datasource drops its entry, since it records a verdict of its own. + */ + private readonly awaitingLend = new Map(); + + /** The lenders whose `onLent` this service subscribed to, one subscription each, with their unsubscribes. */ + private readonly lendSubscriptions = new Map void>(); + constructor(cfg: DatasourceConnectionServiceConfig) { this.cfg = cfg; this.policy = cfg.policy ?? allowAllConnectPolicy; @@ -530,6 +545,42 @@ export class DatasourceConnectionService { }); } + /** + * Subscribe to `source`'s granted borrows, once per lender. `false` when + * there is no lender or it offers no `onLent`, so nothing can retract a + * verdict recorded against it. + */ + private subscribeToLends(source: IConnectorCredentialSource | undefined): boolean { + if (!source || typeof source.onLent !== 'function') return false; + if (!this.lendSubscriptions.has(source)) { + this.lendSubscriptions.set(source, source.onLent((connector) => this.retractOnLend(connector))); + } + return true; + } + + /** + * [ADR-0015 D7] The lender granted a borrow of `connector`: every + * already-registered-guard verdict waiting on THAT instance is retracted — + * re-recorded `already-registered`, which clears the engine's unavailable + * mark — provided the retained state is still the verdict recorded and the + * plugin's driver is still registered. A grant for any other instance + * proves nothing about this one and leaves it standing. + */ + private retractOnLend(connector: string): void { + for (const [name, awaiting] of this.awaitingLend) { + if (awaiting.connector !== connector) continue; + this.awaitingLend.delete(name); + const state = this.states.get(name); + if (state?.status !== 'failed-credentials' || state.reason !== awaiting.reason) continue; + if (!this.cfg.engine()?.getDriverByName?.(name)) continue; + this.logger?.info?.( + `datasource '${name}': connector instance '${connector}' lent its credential again — the boot's ` + + `failed-credentials verdict is retracted`, + ); + this.recordState({ name, status: 'already-registered' }, undefined); + } + } + private async attemptConnect( record: ConnectableDatasource, opts: { objects?: readonly string[]; mappedObjects?: readonly string[]; context?: DatasourceConnectContext; asDefault?: boolean } = {}, @@ -537,6 +588,7 @@ export class DatasourceConnectionService { const name = record.name; const engine = this.cfg.engine(); const factory = this.cfg.factory(); + this.awaitingLend.delete(name); // Idempotent: never double-register (e.g. a legacy `onEnable` bridge already // registered this driver — the D8 escape hatch). The default driver keeps @@ -558,11 +610,35 @@ export class DatasourceConnectionService { // registered it (#13578), and it refuses each request on its own — but // the datasource is recorded failed (Setup → Datasources names why) and, // when objects bind to it, the boot fails as for any connect failure. + // + // The verdict is the boot's judgment of the declaration, and the driver + // borrows again on every request, so once the instance is repaired the + // first borrow the lender grants for it retracts the verdict and the + // engine's unavailable mark (`retractOnLend`) — no restart. A lender + // without `onLent`, or none at all, leaves the verdict until a restart, + // and the degraded-boot banner says which. const connector = borrowedConnectorOf(record.external); if (connector !== undefined) { - const borrow = await borrowConnectorCredential(this.cfg.connectorCredentials?.(), connector); + const source = this.cfg.connectorCredentials?.(); + const borrow = await borrowConnectorCredential(source, connector); if (!borrow.ok) { - return this.handleFailure(record, 'failed-credentials', borrow.reason, opts.context, opts.objects, opts.mappedObjects, borrow.cause); + const retractable = this.subscribeToLends(source); + const result = this.handleFailure( + record, + 'failed-credentials', + borrow.reason, + opts.context, + opts.objects, + opts.mappedObjects, + borrow.cause, + retractable + ? `until connector instance '${connector}' can lend its credential: the driver borrows again on every ` + + `request, and the first borrow granted for '${connector}' clears this verdict and the unavailable ` + + `mark, with no restart.` + : undefined, + ); + if (retractable) this.awaitingLend.set(name, { connector, reason: borrow.reason }); + return result; } } return { name, status: 'already-registered' }; @@ -821,6 +897,7 @@ export class DatasourceConnectionService { // behind would make the admin list — and the query-time error — describe a // datasource that no longer exists in that state. this.states.delete(name); + this.awaitingLend.delete(name); engine?.clearDatasourceUnavailable?.(name); // #13578 — the eviction this method never did. Closing the pool above only @@ -999,6 +1076,9 @@ export class DatasourceConnectionService { for (const n of names) { await this.disconnect(n, { asDefault: n === DEFAULT_DATASOURCE_NAME }); } + for (const unsubscribe of this.lendSubscriptions.values()) unsubscribe(); + this.lendSubscriptions.clear(); + this.awaitingLend.clear(); } /** @@ -1063,6 +1143,12 @@ export class DatasourceConnectionService { * credential) — those are never module-resolution failures. */ cause?: unknown, + /** + * How the datasource recovers, for the degraded-boot banner, when that is + * not the default "until it is reachable AND the server is restarted" + * (the already-registered guard's verdict clears on the next lend). + */ + recovery?: string, ): ConnectResult { const isExternal = record.schemaMode && record.schemaMode !== 'managed'; const msg = `datasource '${record.name}': connect failed — ${reason}`; @@ -1104,7 +1190,7 @@ export class DatasourceConnectionService { const banner = `⚠️ DEGRADED BOOT: ${msg} (${why}), but OS_ALLOW_DRIVER_CONNECT_FAILURE is set — starting ` + `anyway. Queries against the objects bound to it fail with ERR_DATASOURCE_UNAVAILABLE (HTTP ` + - `503) until it is reachable AND the server is restarted: nothing re-runs the connect. ` + + `503) ${recovery ?? 'until it is reachable AND the server is restarted: nothing re-runs the connect.'} ` + `Its state shows as 'error' in Setup → Datasources. Unset OS_ALLOW_DRIVER_CONNECT_FAILURE ` + `to restore fail-fast boot.`; this.logger?.warn?.(banner); diff --git a/packages/types/src/connector-credential-source.ts b/packages/types/src/connector-credential-source.ts index 45cbefb6952..c63a39216f3 100644 --- a/packages/types/src/connector-credential-source.ts +++ b/packages/types/src/connector-credential-source.ts @@ -123,6 +123,20 @@ export interface IConnectorCredentialSource { * `503 SERVICE_UNAVAILABLE` when the declaration cannot be read now. */ borrow(connector: string): Promise; + /** + * Subscribe to the borrows this source grants: `listener` is called with + * the connector instance's name each time {@link borrow} lends a handle, + * whoever asked — a driver's request, a connect, a probe. Returns the + * unsubscribe. A listener's throw is the listener's own and never fails + * the borrow. + * + * A borrow judges the declaration, so a lend proves that the instance can + * lend NOW. A consumer that recorded "this instance cannot lend" uses it + * to retract that record once the instance is repaired, rather than + * carrying it until a restart. Optional: a source without it leaves such + * a record standing. + */ + onLent?(listener: (connector: string) => void): () => void; } /** A refusal of this seam: a standard-catalog `code` and its `status` on the error, the message naming the instance. */ @@ -147,8 +161,9 @@ function base64Unpadded(text: string): string { * base64(`username:password`), the `Authorization: Basic` header. A base64 * form is kept without its `=` padding, so an echo that drops it is caught * too. Longest first, so a longer form is replaced before a shorter one - * inside it. The same spellings `@objectstack/service-automation`'s - * connector call path withholds. + * inside it. `@objectstack/service-automation`'s connector call path takes + * its list from here too, so the connector and a datasource borrowing its + * credential withhold the same spellings. */ export function borrowedCredentialForms(auth: BorrowedConnectorAuth): string[] { const value = auth.type === 'bearer' ? auth.token : auth.type === 'api-key' ? auth.key : auth.password;