@hydraharness/harness-credentials 0.1.1-rc.10
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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/lib/index.js +158 -0
- package/lib/invariant.js +31 -0
- package/lib/types/index.d.ts +225 -0
- package/lib/types/index.js +177 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +31 -0
- package/lib/types/types.d.ts +82 -0
- package/lib/types/types.js +10 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @hydraharness/harness-credentials
|
|
2
|
+
|
|
3
|
+
Credential Service Definition (`ctx.credentials`). One doctrine, three consequences:
|
|
4
|
+
|
|
5
|
+
**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file.
|
|
6
|
+
|
|
7
|
+
**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin.
|
|
8
|
+
|
|
9
|
+
**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret.
|
|
10
|
+
|
|
11
|
+
## Two key spaces, two questions
|
|
12
|
+
|
|
13
|
+
A `CredentialRef` answers *what is behind this environment-variable name*, layered over the process environment, the managed store, and `.env` files. Everything above describes that half.
|
|
14
|
+
|
|
15
|
+
A `CredentialKey` answers *what credential does this plugin hold for this id*. Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and the empty-value rule does not apply: an `api-key` record carrying neither a key nor environment values states that its owner confirmed ambient authentication, which is configured.
|
|
16
|
+
|
|
17
|
+
The key is `<scope>/<id>`, where `scope` is the **owning plugin's registered name**. The scope is the owner rather than the domain because a `grant` payload is written in its owner's format: two plugins serving the same provider name would otherwise read each other's payload, and a record left behind by an uninstalled plugin could not be told apart from a live one. The `/` also keeps the two grammars disjoint, so the key spaces can never collide. A consumer whose id arrives from somewhere else — a settings dict key, a library's own provider id — asks `isCredentialKeySegment` before building a key, because an id outside the grammar can never have stored a record and should read as "nothing stored" rather than throw on the address.
|
|
18
|
+
|
|
19
|
+
## Surface
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import type { Context } from '@hydraharness/cordis'
|
|
23
|
+
import { credentialKey, credentialRef } from '@hydraharness/harness-credentials'
|
|
24
|
+
|
|
25
|
+
declare const ctx: Context
|
|
26
|
+
|
|
27
|
+
const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
|
|
28
|
+
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
|
|
29
|
+
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
|
|
30
|
+
await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
|
|
31
|
+
await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
|
|
32
|
+
|
|
33
|
+
const key = credentialKey('llm-pi-ai', 'openai-codex') // <owner>/<id>, branded
|
|
34
|
+
await ctx.credentials.readRecord(key) // CredentialRecord | undefined
|
|
35
|
+
await ctx.credentials.describeRecord(key) // { configured, kind?, writable } — never the value
|
|
36
|
+
await ctx.credentials.listRecords() // [{ key, kind }] — never values
|
|
37
|
+
await ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { token: '…' } }))
|
|
38
|
+
await ctx.credentials.deleteRecord(key) // no-op when absent
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`modifyRecord` is the only write path because a correct write depends on the current value: a token refresh is read-decide-replace, and the mutation must see the record as it stands at the moment the write is exclusive. Exclusion holds across processes, which is what stops two of them rotating one refresh token and losing whichever wrote first. Returning `undefined` from the mutation leaves the entry untouched and announces nothing.
|
|
42
|
+
|
|
43
|
+
`listRecords` exists even though the reference half has no enumeration by design. References are discovered from settings schemas (`apiKeyEnv` fields); records have no such path, so a surface that cannot list them cannot show what a user is authorized for, nor find an orphan left by an uninstalled plugin.
|
|
44
|
+
|
|
45
|
+
A `grant` record's `payload` is opaque: the seam never reads, validates, or reshapes it. The one constraint is that it survives a JSON round trip, which the provider enforces on the way in and on the way out — a value the store could not read back exactly as written is refused rather than stored lossily.
|
|
46
|
+
|
|
47
|
+
`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. Its declaration lives in the client-safe `./types` subpath export together with the `CredentialRef` type it names (the package root re-exports the type), so a consumer outside the Host compilation face reads the very signature the Host emits instead of restating it.
|
|
48
|
+
|
|
49
|
+
The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front.
|
|
50
|
+
|
|
51
|
+
## Providers
|
|
52
|
+
|
|
53
|
+
[`@hydraharness/harness-credentials-local`](../credentials-local/README.md) layers the inherited process environment over its managed `$HYDRA_HOME/.credentials.yaml` document, with the launcher's project and user `.env` layers as fallbacks. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets.
|
|
54
|
+
|
|
55
|
+
## Model Experience
|
|
56
|
+
|
|
57
|
+
Indirectly, through the consuming LLM adapters: a resolved value authorizes their provider requests, and the adapter owns every model-visible surface.
|
|
58
|
+
|
|
59
|
+
#### KV Cache effect
|
|
60
|
+
|
|
61
|
+
No direct invalidation; credentials never enter a request prefix.
|
|
62
|
+
|
|
63
|
+
## Known Limitations and Deferred Work
|
|
64
|
+
|
|
65
|
+
- **References have no enumeration** — the seam answers questions about references it is given; configuration surfaces learn them from settings schemas, so a `list()` over that half has no current consumer. Records do enumerate, for the reason above.
|
|
66
|
+
- **References are environment-variable-shaped** — one flat POSIX-identifier namespace, because a reference doubles as the environment name it resolves through. Records carry the richer `<owner>/<id>` addressing.
|
|
67
|
+
- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation.
|
|
68
|
+
- **A record's owner is its scope, and nothing verifies the scope is mounted** — the seam stores what it is given and reports what it stores. Recognizing an orphan is the caller's join between `listRecords()` and whatever registry owns that scope; the seam has no registry of its own to check against.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Service } from "@hydraharness/cordis";
|
|
2
|
+
//#region lib/types/index.js
|
|
3
|
+
/**
|
|
4
|
+
* Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
|
|
5
|
+
* *references* to secrets — environment-variable names — while providers own
|
|
6
|
+
* the actual values and their storage. Consumers resolve a reference once per
|
|
7
|
+
* operation, so a changed credential reaches the next operation without any
|
|
8
|
+
* plugin restart, and configuration surfaces describe a reference without
|
|
9
|
+
* ever seeing its value.
|
|
10
|
+
* @module @hydraharness/harness-credentials
|
|
11
|
+
*/
|
|
12
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
/** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
|
|
14
|
+
const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
15
|
+
/**
|
|
16
|
+
* Brand a raw string as a {@link CredentialRef}.
|
|
17
|
+
* @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
|
|
18
|
+
* @returns the branded reference.
|
|
19
|
+
*/
|
|
20
|
+
function credentialRef(value) {
|
|
21
|
+
if (!isCredentialRefName(value)) throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether a raw string could name a reference at all. Consumers that receive
|
|
26
|
+
* environment-variable names from somewhere else — a provider library's own
|
|
27
|
+
* ambient discovery, a hook payload — ask this before resolving, because a name
|
|
28
|
+
* outside the grammar has no reference to miss and should read as "not set"
|
|
29
|
+
* rather than as a thrown error.
|
|
30
|
+
* @param value - candidate reference.
|
|
31
|
+
* @returns true when {@link credentialRef} would accept it.
|
|
32
|
+
*/
|
|
33
|
+
function isCredentialRefName(value) {
|
|
34
|
+
return REF_PATTERN.test(value);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether a raw string could be a {@link credentialKey} segment at all.
|
|
38
|
+
* Consumers whose addressing units come from somewhere else — a settings dict
|
|
39
|
+
* key, a library's own provider id — ask this before building a key, because a
|
|
40
|
+
* unit outside the grammar can never have stored a record and should read as
|
|
41
|
+
* "nothing stored" rather than as a thrown error.
|
|
42
|
+
* @param value - candidate segment.
|
|
43
|
+
* @returns true when {@link credentialKey} would accept it as either segment.
|
|
44
|
+
*/
|
|
45
|
+
function isCredentialKeySegment(value) {
|
|
46
|
+
return KEY_SEGMENT_PATTERN.test(value);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Brand a scope and an id as a {@link CredentialKey}.
|
|
50
|
+
* @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
|
|
51
|
+
* @param id - that plugin's own addressing unit, such as a provider route key.
|
|
52
|
+
* @returns the branded key.
|
|
53
|
+
* @throws TypeError when either segment is not a lowercase hyphenated identifier.
|
|
54
|
+
*/
|
|
55
|
+
function credentialKey(scope, id) {
|
|
56
|
+
for (const segment of [scope, id]) if (!KEY_SEGMENT_PATTERN.test(segment)) throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
|
|
57
|
+
return `${scope}/${id}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
|
|
61
|
+
* read half of {@link credentialKey}, for a provider admitting keys off disk.
|
|
62
|
+
* @param value - candidate key in its joined form.
|
|
63
|
+
* @returns the branded key.
|
|
64
|
+
* @throws TypeError when the value is not exactly two valid segments.
|
|
65
|
+
*/
|
|
66
|
+
function parseCredentialKey(value) {
|
|
67
|
+
const segments = value.split("/");
|
|
68
|
+
const [scope, id] = segments;
|
|
69
|
+
if (segments.length !== 2 || scope === void 0 || id === void 0) throw new TypeError(`credential key "${value}" must be "<scope>/<id>"`);
|
|
70
|
+
return credentialKey(scope, id);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The owning plugin's name for one key. A record whose scope names no
|
|
74
|
+
* currently registered owner is an orphan, which a configuration surface must
|
|
75
|
+
* report as such rather than as a working credential.
|
|
76
|
+
* @param key - the key to read.
|
|
77
|
+
* @returns the scope segment.
|
|
78
|
+
*/
|
|
79
|
+
function credentialKeyScope(key) {
|
|
80
|
+
return key.slice(0, key.indexOf("/"));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The owning plugin's own addressing unit for one key — the half that plugin
|
|
84
|
+
* chose, such as a provider route.
|
|
85
|
+
* @param key - the key to read.
|
|
86
|
+
* @returns the id segment.
|
|
87
|
+
*/
|
|
88
|
+
function credentialKeyId(key) {
|
|
89
|
+
return key.slice(key.indexOf("/") + 1);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Abstract credential service over two key spaces that answer two questions.
|
|
93
|
+
*
|
|
94
|
+
* A {@link CredentialRef} answers "what is behind this environment-variable
|
|
95
|
+
* name", layered over the process environment, the provider-managed store, and
|
|
96
|
+
* `.env` files. One seam-wide rule binds that half: an empty stored value is
|
|
97
|
+
* absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
|
|
98
|
+
* so a blank never masquerades as a configured secret.
|
|
99
|
+
*
|
|
100
|
+
* A {@link CredentialKey} answers "what credential does this plugin hold for
|
|
101
|
+
* this id". Nothing can layer here — an authorization grant has no
|
|
102
|
+
* environment to be read from — so presence of the record is the whole fact,
|
|
103
|
+
* and {@link modifyRecord} is the only write path because a correct write
|
|
104
|
+
* depends on the current value (a token refresh is read-decide-replace under
|
|
105
|
+
* one lock).
|
|
106
|
+
*/
|
|
107
|
+
var CredentialProvider = class extends Service {
|
|
108
|
+
constructor(ctx) {
|
|
109
|
+
super(ctx, "credentials");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Fan `credentials/reference-updated` out with contained listener failures: every
|
|
113
|
+
* listener runs, and a sync throw or async rejection is logged without
|
|
114
|
+
* changing the committed operation's outcome — except `INVARIANT`-coded
|
|
115
|
+
* failures, which rethrow after every listener ran (the rethrow reaches the
|
|
116
|
+
* caller only from synchronous listeners, so invariant checks on this event
|
|
117
|
+
* must not be async functions). Providers call this only after the write or
|
|
118
|
+
* reload actually committed, so a broken observer can never make a durable
|
|
119
|
+
* change look failed.
|
|
120
|
+
* @param ref - the reference whose stored value changed.
|
|
121
|
+
*/
|
|
122
|
+
notifyUpdated(ref) {
|
|
123
|
+
this.fanOut("credentials/reference-updated", ref);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Fan `credentials/record-updated` out on exactly the terms
|
|
127
|
+
* {@link notifyUpdated} documents, for the record half of the seam.
|
|
128
|
+
* @param key - the record whose stored value changed.
|
|
129
|
+
*/
|
|
130
|
+
notifyRecordUpdated(key) {
|
|
131
|
+
this.fanOut("credentials/record-updated", key);
|
|
132
|
+
}
|
|
133
|
+
/** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
|
|
134
|
+
fanOut(event, subject) {
|
|
135
|
+
let invariantFailure;
|
|
136
|
+
const args = [event, subject];
|
|
137
|
+
for (const listener of this.ctx.events.dispatch("emit", args)) try {
|
|
138
|
+
const returned = listener(subject);
|
|
139
|
+
if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
|
|
140
|
+
this.warnListenerFailure(event, subject, error);
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error?.code === "INVARIANT") {
|
|
144
|
+
invariantFailure ??= error;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
this.warnListenerFailure(event, subject, error);
|
|
148
|
+
}
|
|
149
|
+
if (invariantFailure !== void 0) throw invariantFailure;
|
|
150
|
+
}
|
|
151
|
+
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
152
|
+
warnListenerFailure(event, subject, error) {
|
|
153
|
+
this.ctx.logger.warn("credentials: a %s listener for \"%s\" failed", event, subject);
|
|
154
|
+
this.ctx.logger.warn(error);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
//#endregion
|
|
158
|
+
export { CredentialProvider, CredentialProvider as default, credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName, parseCredentialKey };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-credentials`.
|
|
4
|
+
* @module @hydraharness/harness-credentials/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-credentials";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "credentials-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* Install the commit-event lifecycle contract: `credentials/reference-updated` names a
|
|
13
|
+
* committed provider-source change, so it can only fire while a credentials
|
|
14
|
+
* service is live — an emission after disposal means a provider leaked work
|
|
15
|
+
* past its teardown quiescence. The value relation itself (`describe`
|
|
16
|
+
* agreeing with `resolve`) is asynchronous provider I/O and stays pinned by
|
|
17
|
+
* each provider's own suite.
|
|
18
|
+
*/
|
|
19
|
+
const install = (ctx, fail) => {
|
|
20
|
+
ctx.on("credentials/reference-updated", (ref) => {
|
|
21
|
+
if (ctx.get("credentials") === void 0) fail(`credentials/reference-updated for "${ref}" emitted without a live credentials service`);
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Register this package's invariant companion.
|
|
26
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
27
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
28
|
+
*/
|
|
29
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
30
|
+
//#endregion
|
|
31
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
|
|
3
|
+
* *references* to secrets — environment-variable names — while providers own
|
|
4
|
+
* the actual values and their storage. Consumers resolve a reference once per
|
|
5
|
+
* operation, so a changed credential reaches the next operation without any
|
|
6
|
+
* plugin restart, and configuration surfaces describe a reference without
|
|
7
|
+
* ever seeing its value.
|
|
8
|
+
* @module @hydraharness/harness-credentials
|
|
9
|
+
*/
|
|
10
|
+
import { Context, Service } from '@hydraharness/cordis';
|
|
11
|
+
import type { CredentialKey, CredentialRecord, CredentialRef } from './types.ts';
|
|
12
|
+
export type { ApiKeyRecord, CredentialKey, CredentialRecord, CredentialRef, GrantRecord } from './types.ts';
|
|
13
|
+
/**
|
|
14
|
+
* Brand a raw string as a {@link CredentialRef}.
|
|
15
|
+
* @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
|
|
16
|
+
* @returns the branded reference.
|
|
17
|
+
*/
|
|
18
|
+
export declare function credentialRef(value: string): CredentialRef;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a raw string could name a reference at all. Consumers that receive
|
|
21
|
+
* environment-variable names from somewhere else — a provider library's own
|
|
22
|
+
* ambient discovery, a hook payload — ask this before resolving, because a name
|
|
23
|
+
* outside the grammar has no reference to miss and should read as "not set"
|
|
24
|
+
* rather than as a thrown error.
|
|
25
|
+
* @param value - candidate reference.
|
|
26
|
+
* @returns true when {@link credentialRef} would accept it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isCredentialRefName(value: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a raw string could be a {@link credentialKey} segment at all.
|
|
31
|
+
* Consumers whose addressing units come from somewhere else — a settings dict
|
|
32
|
+
* key, a library's own provider id — ask this before building a key, because a
|
|
33
|
+
* unit outside the grammar can never have stored a record and should read as
|
|
34
|
+
* "nothing stored" rather than as a thrown error.
|
|
35
|
+
* @param value - candidate segment.
|
|
36
|
+
* @returns true when {@link credentialKey} would accept it as either segment.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isCredentialKeySegment(value: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Brand a scope and an id as a {@link CredentialKey}.
|
|
41
|
+
* @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
|
|
42
|
+
* @param id - that plugin's own addressing unit, such as a provider route key.
|
|
43
|
+
* @returns the branded key.
|
|
44
|
+
* @throws TypeError when either segment is not a lowercase hyphenated identifier.
|
|
45
|
+
*/
|
|
46
|
+
export declare function credentialKey(scope: string, id: string): CredentialKey;
|
|
47
|
+
/**
|
|
48
|
+
* Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
|
|
49
|
+
* read half of {@link credentialKey}, for a provider admitting keys off disk.
|
|
50
|
+
* @param value - candidate key in its joined form.
|
|
51
|
+
* @returns the branded key.
|
|
52
|
+
* @throws TypeError when the value is not exactly two valid segments.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseCredentialKey(value: string): CredentialKey;
|
|
55
|
+
/**
|
|
56
|
+
* The owning plugin's name for one key. A record whose scope names no
|
|
57
|
+
* currently registered owner is an orphan, which a configuration surface must
|
|
58
|
+
* report as such rather than as a working credential.
|
|
59
|
+
* @param key - the key to read.
|
|
60
|
+
* @returns the scope segment.
|
|
61
|
+
*/
|
|
62
|
+
export declare function credentialKeyScope(key: CredentialKey): string;
|
|
63
|
+
/**
|
|
64
|
+
* The owning plugin's own addressing unit for one key — the half that plugin
|
|
65
|
+
* chose, such as a provider route.
|
|
66
|
+
* @param key - the key to read.
|
|
67
|
+
* @returns the id segment.
|
|
68
|
+
*/
|
|
69
|
+
export declare function credentialKeyId(key: CredentialKey): string;
|
|
70
|
+
/** One resolved credential value and the source layer that supplied it. */
|
|
71
|
+
export interface ResolvedCredential {
|
|
72
|
+
/** The non-empty secret value. */
|
|
73
|
+
value: string;
|
|
74
|
+
/** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */
|
|
75
|
+
source: string;
|
|
76
|
+
}
|
|
77
|
+
/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
|
|
78
|
+
export interface CredentialInfo {
|
|
79
|
+
/** Whether {@link CredentialProvider.resolve} would currently return a value. */
|
|
80
|
+
configured: boolean;
|
|
81
|
+
/** Source layer currently supplying the value; absent while unconfigured. */
|
|
82
|
+
source?: string;
|
|
83
|
+
/** Whether {@link CredentialProvider.set} would currently succeed for this reference. */
|
|
84
|
+
writable: boolean;
|
|
85
|
+
}
|
|
86
|
+
/** Presence and writability facts for one record, safe for configuration UIs — never the value. */
|
|
87
|
+
export interface CredentialRecordInfo {
|
|
88
|
+
/**
|
|
89
|
+
* Whether a record is stored. Unlike a reference, presence alone answers
|
|
90
|
+
* this: an {@link ApiKeyRecord} carrying neither a key nor environment
|
|
91
|
+
* values states that its owner confirmed ambient authentication, which is
|
|
92
|
+
* configured, not blank.
|
|
93
|
+
*/
|
|
94
|
+
configured: boolean;
|
|
95
|
+
/** Discriminant of the stored record; absent while none is stored. */
|
|
96
|
+
kind?: CredentialRecord['kind'];
|
|
97
|
+
/** Whether {@link CredentialProvider.modifyRecord} would currently succeed. */
|
|
98
|
+
writable: boolean;
|
|
99
|
+
}
|
|
100
|
+
/** One stored record's address and tag, for enumeration — never its value. */
|
|
101
|
+
export interface CredentialRecordEntry {
|
|
102
|
+
/** The record's address. */
|
|
103
|
+
key: CredentialKey;
|
|
104
|
+
/** Discriminant of the stored record. */
|
|
105
|
+
kind: CredentialRecord['kind'];
|
|
106
|
+
}
|
|
107
|
+
declare module '@hydraharness/cordis' {
|
|
108
|
+
interface Context {
|
|
109
|
+
credentials: CredentialProvider;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Abstract credential service over two key spaces that answer two questions.
|
|
114
|
+
*
|
|
115
|
+
* A {@link CredentialRef} answers "what is behind this environment-variable
|
|
116
|
+
* name", layered over the process environment, the provider-managed store, and
|
|
117
|
+
* `.env` files. One seam-wide rule binds that half: an empty stored value is
|
|
118
|
+
* absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
|
|
119
|
+
* so a blank never masquerades as a configured secret.
|
|
120
|
+
*
|
|
121
|
+
* A {@link CredentialKey} answers "what credential does this plugin hold for
|
|
122
|
+
* this id". Nothing can layer here — an authorization grant has no
|
|
123
|
+
* environment to be read from — so presence of the record is the whole fact,
|
|
124
|
+
* and {@link modifyRecord} is the only write path because a correct write
|
|
125
|
+
* depends on the current value (a token refresh is read-decide-replace under
|
|
126
|
+
* one lock).
|
|
127
|
+
*/
|
|
128
|
+
export declare abstract class CredentialProvider extends Service {
|
|
129
|
+
constructor(ctx: Context);
|
|
130
|
+
/**
|
|
131
|
+
* Resolve one reference to its current value. Resolution is per call:
|
|
132
|
+
* consumers re-resolve at each operation and must not cache across
|
|
133
|
+
* operations — that per-operation read is what makes a changed credential
|
|
134
|
+
* reach the next operation without a restart.
|
|
135
|
+
* @param ref - the reference to resolve.
|
|
136
|
+
* @returns the value and its source, or `undefined` while unconfigured.
|
|
137
|
+
*/
|
|
138
|
+
abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>;
|
|
139
|
+
/**
|
|
140
|
+
* Describe one reference for configuration surfaces without exposing the
|
|
141
|
+
* value.
|
|
142
|
+
* @param ref - the reference to describe.
|
|
143
|
+
* @returns configured state, supplying source, and writability.
|
|
144
|
+
*/
|
|
145
|
+
abstract describe(ref: CredentialRef): Promise<CredentialInfo>;
|
|
146
|
+
/**
|
|
147
|
+
* Durably store one value in the provider-managed writable source. Rejects
|
|
148
|
+
* while a read-only source shadows the reference — the write would appear
|
|
149
|
+
* to succeed while resolution keeps returning the shadowing value — and
|
|
150
|
+
* rejects an empty value (use {@link unset}).
|
|
151
|
+
* @param ref - the reference to store.
|
|
152
|
+
* @param value - the non-empty secret value.
|
|
153
|
+
*/
|
|
154
|
+
abstract set(ref: CredentialRef, value: string): Promise<void>;
|
|
155
|
+
/**
|
|
156
|
+
* Remove one reference from the provider-managed writable source; removing
|
|
157
|
+
* an absent reference is a no-op. Rejects while a read-only source shadows
|
|
158
|
+
* the reference, like {@link set}.
|
|
159
|
+
* @param ref - the reference to remove.
|
|
160
|
+
*/
|
|
161
|
+
abstract unset(ref: CredentialRef): Promise<void>;
|
|
162
|
+
/**
|
|
163
|
+
* Read one stored record. The value is returned as its owner wrote it; a
|
|
164
|
+
* {@link GrantRecord} payload is not interpreted on the way out.
|
|
165
|
+
* @param key - the record to read.
|
|
166
|
+
* @returns the record, or `undefined` while none is stored.
|
|
167
|
+
*/
|
|
168
|
+
abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>;
|
|
169
|
+
/**
|
|
170
|
+
* Describe one record for configuration surfaces without exposing its value.
|
|
171
|
+
* @param key - the record to describe.
|
|
172
|
+
* @returns presence, discriminant, and writability.
|
|
173
|
+
*/
|
|
174
|
+
abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>;
|
|
175
|
+
/**
|
|
176
|
+
* Enumerate every stored record's address and tag. Unlike the reference
|
|
177
|
+
* half, which has no enumeration because configuration surfaces learn which
|
|
178
|
+
* references exist from settings schemas, records have no such discovery
|
|
179
|
+
* path: a surface that cannot list them cannot show what a user is
|
|
180
|
+
* authorized for, nor find an orphan left by an uninstalled plugin.
|
|
181
|
+
* @returns every stored record, values excluded.
|
|
182
|
+
*/
|
|
183
|
+
abstract listRecords(): Promise<readonly CredentialRecordEntry[]>;
|
|
184
|
+
/**
|
|
185
|
+
* Serialized read-modify-write over one record — the only write path.
|
|
186
|
+
* `mutate` sees the record as it stands at the moment the write is
|
|
187
|
+
* exclusive, and returning `undefined` leaves the entry untouched. Exclusion
|
|
188
|
+
* holds across processes where the backing store supports it, which is what
|
|
189
|
+
* makes a token refresh safe: two processes rotating one refresh token
|
|
190
|
+
* concurrently would otherwise lose whichever wrote first.
|
|
191
|
+
* @param key - the record to modify.
|
|
192
|
+
* @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
|
|
193
|
+
* @returns the record after the write, or the current one when `mutate` declined.
|
|
194
|
+
*/
|
|
195
|
+
abstract modifyRecord(key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>): Promise<CredentialRecord | undefined>;
|
|
196
|
+
/**
|
|
197
|
+
* Remove one record; removing an absent record is a no-op.
|
|
198
|
+
* @param key - the record to remove.
|
|
199
|
+
*/
|
|
200
|
+
abstract deleteRecord(key: CredentialKey): Promise<void>;
|
|
201
|
+
/**
|
|
202
|
+
* Fan `credentials/reference-updated` out with contained listener failures: every
|
|
203
|
+
* listener runs, and a sync throw or async rejection is logged without
|
|
204
|
+
* changing the committed operation's outcome — except `INVARIANT`-coded
|
|
205
|
+
* failures, which rethrow after every listener ran (the rethrow reaches the
|
|
206
|
+
* caller only from synchronous listeners, so invariant checks on this event
|
|
207
|
+
* must not be async functions). Providers call this only after the write or
|
|
208
|
+
* reload actually committed, so a broken observer can never make a durable
|
|
209
|
+
* change look failed.
|
|
210
|
+
* @param ref - the reference whose stored value changed.
|
|
211
|
+
*/
|
|
212
|
+
protected notifyUpdated(ref: CredentialRef): void;
|
|
213
|
+
/**
|
|
214
|
+
* Fan `credentials/record-updated` out on exactly the terms
|
|
215
|
+
* {@link notifyUpdated} documents, for the record half of the seam.
|
|
216
|
+
* @param key - the record whose stored value changed.
|
|
217
|
+
*/
|
|
218
|
+
protected notifyRecordUpdated(key: CredentialKey): void;
|
|
219
|
+
/** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
|
|
220
|
+
private fanOut;
|
|
221
|
+
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
222
|
+
private warnListenerFailure;
|
|
223
|
+
}
|
|
224
|
+
export default CredentialProvider;
|
|
225
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
|
|
3
|
+
* *references* to secrets — environment-variable names — while providers own
|
|
4
|
+
* the actual values and their storage. Consumers resolve a reference once per
|
|
5
|
+
* operation, so a changed credential reaches the next operation without any
|
|
6
|
+
* plugin restart, and configuration surfaces describe a reference without
|
|
7
|
+
* ever seeing its value.
|
|
8
|
+
* @module @hydraharness/harness-credentials
|
|
9
|
+
*/
|
|
10
|
+
import { Service } from '@hydraharness/cordis';
|
|
11
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
|
+
/** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
|
|
13
|
+
const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
14
|
+
/**
|
|
15
|
+
* Brand a raw string as a {@link CredentialRef}.
|
|
16
|
+
* @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
|
|
17
|
+
* @returns the branded reference.
|
|
18
|
+
*/
|
|
19
|
+
export function credentialRef(value) {
|
|
20
|
+
if (!isCredentialRefName(value)) {
|
|
21
|
+
throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether a raw string could name a reference at all. Consumers that receive
|
|
27
|
+
* environment-variable names from somewhere else — a provider library's own
|
|
28
|
+
* ambient discovery, a hook payload — ask this before resolving, because a name
|
|
29
|
+
* outside the grammar has no reference to miss and should read as "not set"
|
|
30
|
+
* rather than as a thrown error.
|
|
31
|
+
* @param value - candidate reference.
|
|
32
|
+
* @returns true when {@link credentialRef} would accept it.
|
|
33
|
+
*/
|
|
34
|
+
export function isCredentialRefName(value) {
|
|
35
|
+
return REF_PATTERN.test(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether a raw string could be a {@link credentialKey} segment at all.
|
|
39
|
+
* Consumers whose addressing units come from somewhere else — a settings dict
|
|
40
|
+
* key, a library's own provider id — ask this before building a key, because a
|
|
41
|
+
* unit outside the grammar can never have stored a record and should read as
|
|
42
|
+
* "nothing stored" rather than as a thrown error.
|
|
43
|
+
* @param value - candidate segment.
|
|
44
|
+
* @returns true when {@link credentialKey} would accept it as either segment.
|
|
45
|
+
*/
|
|
46
|
+
export function isCredentialKeySegment(value) {
|
|
47
|
+
return KEY_SEGMENT_PATTERN.test(value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Brand a scope and an id as a {@link CredentialKey}.
|
|
51
|
+
* @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
|
|
52
|
+
* @param id - that plugin's own addressing unit, such as a provider route key.
|
|
53
|
+
* @returns the branded key.
|
|
54
|
+
* @throws TypeError when either segment is not a lowercase hyphenated identifier.
|
|
55
|
+
*/
|
|
56
|
+
export function credentialKey(scope, id) {
|
|
57
|
+
for (const segment of [scope, id]) {
|
|
58
|
+
if (!KEY_SEGMENT_PATTERN.test(segment)) {
|
|
59
|
+
throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return `${scope}/${id}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
|
|
66
|
+
* read half of {@link credentialKey}, for a provider admitting keys off disk.
|
|
67
|
+
* @param value - candidate key in its joined form.
|
|
68
|
+
* @returns the branded key.
|
|
69
|
+
* @throws TypeError when the value is not exactly two valid segments.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCredentialKey(value) {
|
|
72
|
+
const segments = value.split('/');
|
|
73
|
+
const [scope, id] = segments;
|
|
74
|
+
if (segments.length !== 2 || scope === undefined || id === undefined) {
|
|
75
|
+
throw new TypeError(`credential key "${value}" must be "<scope>/<id>"`);
|
|
76
|
+
}
|
|
77
|
+
return credentialKey(scope, id);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The owning plugin's name for one key. A record whose scope names no
|
|
81
|
+
* currently registered owner is an orphan, which a configuration surface must
|
|
82
|
+
* report as such rather than as a working credential.
|
|
83
|
+
* @param key - the key to read.
|
|
84
|
+
* @returns the scope segment.
|
|
85
|
+
*/
|
|
86
|
+
export function credentialKeyScope(key) {
|
|
87
|
+
// The brand's only constructors both validate two segments, so the split
|
|
88
|
+
// cannot come back short here.
|
|
89
|
+
return key.slice(0, key.indexOf('/'));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The owning plugin's own addressing unit for one key — the half that plugin
|
|
93
|
+
* chose, such as a provider route.
|
|
94
|
+
* @param key - the key to read.
|
|
95
|
+
* @returns the id segment.
|
|
96
|
+
*/
|
|
97
|
+
export function credentialKeyId(key) {
|
|
98
|
+
return key.slice(key.indexOf('/') + 1);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Abstract credential service over two key spaces that answer two questions.
|
|
102
|
+
*
|
|
103
|
+
* A {@link CredentialRef} answers "what is behind this environment-variable
|
|
104
|
+
* name", layered over the process environment, the provider-managed store, and
|
|
105
|
+
* `.env` files. One seam-wide rule binds that half: an empty stored value is
|
|
106
|
+
* absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
|
|
107
|
+
* so a blank never masquerades as a configured secret.
|
|
108
|
+
*
|
|
109
|
+
* A {@link CredentialKey} answers "what credential does this plugin hold for
|
|
110
|
+
* this id". Nothing can layer here — an authorization grant has no
|
|
111
|
+
* environment to be read from — so presence of the record is the whole fact,
|
|
112
|
+
* and {@link modifyRecord} is the only write path because a correct write
|
|
113
|
+
* depends on the current value (a token refresh is read-decide-replace under
|
|
114
|
+
* one lock).
|
|
115
|
+
*/
|
|
116
|
+
export class CredentialProvider extends Service {
|
|
117
|
+
constructor(ctx) {
|
|
118
|
+
super(ctx, 'credentials');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fan `credentials/reference-updated` out with contained listener failures: every
|
|
122
|
+
* listener runs, and a sync throw or async rejection is logged without
|
|
123
|
+
* changing the committed operation's outcome — except `INVARIANT`-coded
|
|
124
|
+
* failures, which rethrow after every listener ran (the rethrow reaches the
|
|
125
|
+
* caller only from synchronous listeners, so invariant checks on this event
|
|
126
|
+
* must not be async functions). Providers call this only after the write or
|
|
127
|
+
* reload actually committed, so a broken observer can never make a durable
|
|
128
|
+
* change look failed.
|
|
129
|
+
* @param ref - the reference whose stored value changed.
|
|
130
|
+
*/
|
|
131
|
+
notifyUpdated(ref) {
|
|
132
|
+
this.fanOut('credentials/reference-updated', ref);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Fan `credentials/record-updated` out on exactly the terms
|
|
136
|
+
* {@link notifyUpdated} documents, for the record half of the seam.
|
|
137
|
+
* @param key - the record whose stored value changed.
|
|
138
|
+
*/
|
|
139
|
+
notifyRecordUpdated(key) {
|
|
140
|
+
this.fanOut('credentials/record-updated', key);
|
|
141
|
+
}
|
|
142
|
+
/* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
|
|
143
|
+
fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
|
|
144
|
+
contract, and extracting it would couple the two seams' event semantics. */
|
|
145
|
+
/** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
|
|
146
|
+
fanOut(event, subject) {
|
|
147
|
+
let invariantFailure;
|
|
148
|
+
const args = [event, subject];
|
|
149
|
+
for (const listener of this.ctx.events.dispatch('emit', args)) {
|
|
150
|
+
try {
|
|
151
|
+
const returned = listener(subject);
|
|
152
|
+
if (returned != null && typeof returned.then === 'function') {
|
|
153
|
+
void Promise.resolve(returned).then(undefined, (error) => {
|
|
154
|
+
this.warnListenerFailure(event, subject, error);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error?.code === 'INVARIANT') {
|
|
160
|
+
invariantFailure ??= error;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
this.warnListenerFailure(event, subject, error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (invariantFailure !== undefined)
|
|
167
|
+
throw invariantFailure;
|
|
168
|
+
}
|
|
169
|
+
/* jscpd:ignore-end */
|
|
170
|
+
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
171
|
+
warnListenerFailure(event, subject, error) {
|
|
172
|
+
this.ctx.logger.warn('credentials: a %s listener for "%s" failed', event, subject);
|
|
173
|
+
this.ctx.logger.warn(error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export default CredentialProvider;
|
|
177
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-credentials`.
|
|
3
|
+
* @module @hydraharness/harness-credentials/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "credentials-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-credentials`.
|
|
3
|
+
* @module @hydraharness/harness-credentials/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@hydraharness/harness-credentials';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'credentials-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* Install the commit-event lifecycle contract: `credentials/reference-updated` names a
|
|
12
|
+
* committed provider-source change, so it can only fire while a credentials
|
|
13
|
+
* service is live — an emission after disposal means a provider leaked work
|
|
14
|
+
* past its teardown quiescence. The value relation itself (`describe`
|
|
15
|
+
* agreeing with `resolve`) is asynchronous provider I/O and stays pinned by
|
|
16
|
+
* each provider's own suite.
|
|
17
|
+
*/
|
|
18
|
+
const install = (ctx, fail) => {
|
|
19
|
+
ctx.on('credentials/reference-updated', (ref) => {
|
|
20
|
+
if (ctx.get('credentials') === undefined) {
|
|
21
|
+
fail(`credentials/reference-updated for "${ref}" emitted without a live credentials service`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Register this package's invariant companion.
|
|
27
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
28
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
29
|
+
*/
|
|
30
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
31
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe type surface of the credential seam: the two key brands, the
|
|
3
|
+
* stored-record union, and the seam's Cordis event declarations. Types only —
|
|
4
|
+
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
|
5
|
+
* compilation face reads exactly the signature the Host emits.
|
|
6
|
+
*
|
|
7
|
+
* @module @hydraharness/harness-credentials/types
|
|
8
|
+
*/
|
|
9
|
+
import type { Branded } from '@hydraharness/harness-brand';
|
|
10
|
+
/** Nominal reference to one credential: a POSIX-style environment-variable name. */
|
|
11
|
+
export type CredentialRef = Branded<'CredentialRef'>;
|
|
12
|
+
/**
|
|
13
|
+
* Nominal address of one stored credential record: `<scope>/<id>`, where
|
|
14
|
+
* `scope` is the registered name of the plugin that owns the record and `id`
|
|
15
|
+
* is that plugin's own addressing unit (an LLM adapter uses its provider route
|
|
16
|
+
* key).
|
|
17
|
+
*
|
|
18
|
+
* The scope is the owner rather than the domain because a record's payload is
|
|
19
|
+
* written in its owner's format: two plugins serving the same provider name
|
|
20
|
+
* would otherwise read each other's payload, and a record left behind by an
|
|
21
|
+
* uninstalled plugin could not be told apart from a live one. The `/` also
|
|
22
|
+
* keeps this grammar disjoint from {@link CredentialRef}, so the two key
|
|
23
|
+
* spaces can never collide.
|
|
24
|
+
*/
|
|
25
|
+
export type CredentialKey = Branded<'CredentialKey'>;
|
|
26
|
+
/**
|
|
27
|
+
* A credential the harness itself understands: an api key, provider
|
|
28
|
+
* environment values, or both. Either field may be absent — a record carrying
|
|
29
|
+
* neither states that the owner confirmed this route authenticates from its
|
|
30
|
+
* own ambient discovery, which is a different fact from having no record.
|
|
31
|
+
*/
|
|
32
|
+
export interface ApiKeyRecord {
|
|
33
|
+
/** Discriminant. */
|
|
34
|
+
readonly kind: 'api-key';
|
|
35
|
+
/** The non-empty secret value, when this credential is a key at all. */
|
|
36
|
+
readonly key?: string;
|
|
37
|
+
/** Provider environment values such as `AWS_PROFILE`; names are POSIX identifiers. */
|
|
38
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The product of one authorization grant, kept verbatim for its owner. The
|
|
42
|
+
* seam never reads, validates, or reshapes {@link payload}: it is written in
|
|
43
|
+
* the owning plugin's format and only that plugin can interpret it. The single
|
|
44
|
+
* constraint is that it survives a JSON round trip.
|
|
45
|
+
*/
|
|
46
|
+
export interface GrantRecord {
|
|
47
|
+
/** Discriminant. */
|
|
48
|
+
readonly kind: 'grant';
|
|
49
|
+
/** Owner-defined JSON value; opaque to the seam and to every other plugin. */
|
|
50
|
+
readonly payload: unknown;
|
|
51
|
+
}
|
|
52
|
+
/** One durable credential record, tagged by what the seam may do with it. */
|
|
53
|
+
export type CredentialRecord = ApiKeyRecord | GrantRecord;
|
|
54
|
+
declare module '@hydraharness/cordis' {
|
|
55
|
+
interface Events {
|
|
56
|
+
/**
|
|
57
|
+
* Committed change to a provider-managed credential source: a `set`, an
|
|
58
|
+
* `unset`, or an external edit observed in storage. Ambient
|
|
59
|
+
* process-environment changes are not observable and never emit. Listener
|
|
60
|
+
* failures are contained and logged — a sync throw and an async rejection
|
|
61
|
+
* alike — without changing the committed operation's outcome, except
|
|
62
|
+
* `INVARIANT`-coded failures, which rethrow after every listener ran;
|
|
63
|
+
* that rethrow reaches the emitter only from synchronous listeners, so
|
|
64
|
+
* invariant checks on this event must not be async functions.
|
|
65
|
+
* @param ref - the reference whose stored value changed.
|
|
66
|
+
* @mode emit
|
|
67
|
+
*/
|
|
68
|
+
'credentials/reference-updated'(ref: CredentialRef): void;
|
|
69
|
+
/**
|
|
70
|
+
* Committed change to a stored credential record: a `modifyRecord` that
|
|
71
|
+
* wrote, a `deleteRecord` that removed, or an external edit observed in
|
|
72
|
+
* storage. Separate from `credentials/reference-updated` because the two key
|
|
73
|
+
* grammars are disjoint — a listener that received both on one event could
|
|
74
|
+
* not tell which space a subject belongs to. Listener failures are
|
|
75
|
+
* contained on the same terms as `credentials/reference-updated`.
|
|
76
|
+
* @param key - the record whose stored value changed.
|
|
77
|
+
* @mode emit
|
|
78
|
+
*/
|
|
79
|
+
'credentials/record-updated'(key: CredentialKey): void;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe type surface of the credential seam: the two key brands, the
|
|
3
|
+
* stored-record union, and the seam's Cordis event declarations. Types only —
|
|
4
|
+
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
|
5
|
+
* compilation face reads exactly the signature the Host emits.
|
|
6
|
+
*
|
|
7
|
+
* @module @hydraharness/harness-credentials/types
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-credentials",
|
|
3
|
+
"description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values",
|
|
4
|
+
"version": "0.1.1-rc.10",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MaiHongPhong2010/Hydra-Harness.git",
|
|
11
|
+
"directory": "packages/credentials/credentials"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./types": {
|
|
26
|
+
"types": "./lib/types/types.d.ts",
|
|
27
|
+
"default": "./lib/types/types.js"
|
|
28
|
+
},
|
|
29
|
+
"./src/*": "./src/*",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"lib/index.js",
|
|
34
|
+
"lib/invariant.js",
|
|
35
|
+
"lib/types/**/*.js",
|
|
36
|
+
"lib/types/**/*.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@hydraharness/harness-brand": "^0.1.1-rc.10",
|
|
41
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
42
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.10"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@hydraharness/harness-brand": "^0.1.1-rc.10",
|
|
46
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.10",
|
|
47
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
48
|
+
}
|
|
49
|
+
}
|