@hydraharness/harness-credentials 0.1.1-rc.5

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 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 };
@@ -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.5",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MaiHongPhong1902/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-invariants": "^0.1.1-rc.5",
41
+ "@hydraharness/cordis": "^4.0.2",
42
+ "@hydraharness/harness-brand": "^0.1.1-rc.5"
43
+ },
44
+ "devDependencies": {
45
+ "@hydraharness/cordis": "^4.0.2",
46
+ "@hydraharness/harness-invariants": "^0.1.1-rc.5",
47
+ "@hydraharness/harness-brand": "^0.1.1-rc.5"
48
+ }
49
+ }