@homeflare/alchemy 0.37.4 → 0.37.6
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/dist/linux/directory-read.d.ts +14 -0
- package/dist/linux/directory-read.d.ts.map +1 -0
- package/dist/linux/directory.d.ts.map +1 -1
- package/dist/linux/index.js +22 -6
- package/dist/linux/index.js.map +5 -4
- package/dist/litellm/index.js +12 -3
- package/dist/litellm/index.js.map +3 -3
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +0 -6
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-identity.d.ts +11 -0
- package/dist/openbao/auth-role-identity.d.ts.map +1 -0
- package/dist/openbao/auth-role-wire.d.ts +23 -0
- package/dist/openbao/auth-role-wire.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/bao-http.d.ts +34 -13
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/distilled.d.ts +25 -0
- package/dist/openbao/distilled.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +5 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +358 -305
- package/dist/openbao/index.js.map +13 -9
- package/dist/openbao/policy-identity.d.ts +12 -0
- package/dist/openbao/policy-identity.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +9 -8
- package/dist/openbao/policy-wire.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +5 -0
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/proxmox/index.js +2 -2
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/linux/directory-read.ts +24 -0
- package/src/linux/directory.test.ts +20 -0
- package/src/linux/directory.ts +21 -11
- package/src/litellm/operations.ts +31 -4
- package/src/litellm/pass-through-fetch-client.test.ts +40 -0
- package/src/openbao/auth-role-form.test.ts +11 -9
- package/src/openbao/auth-role-form.ts +0 -19
- package/src/openbao/auth-role-identity.ts +24 -0
- package/src/openbao/auth-role-sdk.test.ts +155 -0
- package/src/openbao/auth-role-wire.test.ts +128 -0
- package/src/openbao/auth-role-wire.ts +89 -0
- package/src/openbao/auth-role.ts +9 -9
- package/src/openbao/bao-http.ts +9 -7
- package/src/openbao/distilled-retry.test.ts +164 -0
- package/src/openbao/distilled.test.ts +146 -0
- package/src/openbao/distilled.ts +88 -0
- package/src/openbao/fake-engines-roles.ts +9 -2
- package/src/openbao/policy-distilled.test.ts +98 -0
- package/src/openbao/policy-identity.ts +24 -0
- package/src/openbao/policy-rename.test.ts +2 -2
- package/src/openbao/policy-wire.test.ts +5 -5
- package/src/openbao/policy-wire.ts +31 -11
- package/src/openbao/policy.ts +10 -13
- package/src/openbao/rename.ts +17 -1
- package/src/proxmox/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
|
@@ -54,14 +54,17 @@ export interface NodeNetworkAttributes {
|
|
|
54
54
|
* `{"errors":{"iface":"interface does not exist"},"data":null,"message":"Parameter verification
|
|
55
55
|
* failed.\n"}` at HTTP 400 — a THIRD shape, different from every other migrated family's
|
|
56
56
|
* measured 500. `@distilled.cloud/proxmox` types this as a genuine typed error,
|
|
57
|
-
* `
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
57
|
+
* `NetworkInterfaceNotFound` (`nodes.ts`, generated from `patches/nodes/network-errors.json`),
|
|
58
|
+
* attached ONLY to `getNodeNetwork`'s error union and matched by the SDK's own
|
|
59
|
+
* `errorEnvelope`/`matchTypedError` against the EXACT sole-field body `errors.iface ===
|
|
60
|
+
* "interface does not exist"` — the precision the code here used to check by hand
|
|
61
|
+
* (`error.errors['iface'] === '…'`) is now the SDK's own matcher contract, not this file's.
|
|
62
|
+
* Any 400 that fails that exact match (a different field, a different message, more than one
|
|
63
|
+
* field) decodes as the generic `ParameterVerificationFailed` instead, never this tag.
|
|
64
|
+
* ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason
|
|
65
|
+
* (`ParameterVerificationFailed`), a permission gap, a network blip, or any other failure is
|
|
66
|
+
* never read as "this interface is not there" — `diff` calls this directly and propagates
|
|
67
|
+
* every one of them loudly.
|
|
65
68
|
*/
|
|
66
69
|
export declare const readInterfaceOrFail: (props: NodeNetworkProps) => Effect.Effect<typeof UNREADABLE | NodeNetworkAttributes | undefined, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | import("@distilled.cloud/caddy").Conflict | Error | import("@distilled.cloud/caddy").Forbidden | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/caddy").Locked | import("@distilled.cloud/caddy").NotFound | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError | import("@distilled.cloud/caddy").UnprocessableEntity, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
67
70
|
/**
|
|
@@ -76,8 +79,9 @@ export declare const readInterfaceOrFail: (props: NodeNetworkProps) => Effect.Ef
|
|
|
76
79
|
* interrupted-create recovery, and Apply.ts's delete recovery, all call `read` with
|
|
77
80
|
* `output: undefined` and NO catch of their own (`Plan.ts` v2.0.0-beta.79 ~line 1308) — and
|
|
78
81
|
* Plan.ts fails the WHOLE PLAN, every other resource included, if that call throws. A malformed
|
|
79
|
-
* `iface` in ONE brand-new declaration (a
|
|
80
|
-
* absence
|
|
82
|
+
* `iface` in ONE brand-new declaration (a `ParameterVerificationFailed`, DIFFERENT from the
|
|
83
|
+
* `NetworkInterfaceNotFound` absence tag) would abort `bun run plan` for the entire stack under
|
|
84
|
+
* the precise-only design,
|
|
81
85
|
* where every other migrated family instead defers that same malformed declaration to a scoped,
|
|
82
86
|
* loud refusal at apply. This restores that same fold for those three call sites.
|
|
83
87
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node-network-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/node-network-wire.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EACL,KAAK,gBAAgB,EAOtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,WAAW,GACX,YAAY,GACZ,SAAS,GACT,OAAO,GACP,MAAM,GACN,QAAQ,GACR,KAAK,GACL,QAAQ,GACR,SAAS,GACT,MAAM,GACN,MAAM,CAAC;AAEX;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AA4DD
|
|
1
|
+
{"version":3,"file":"node-network-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/node-network-wire.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EACL,KAAK,gBAAgB,EAOtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,WAAW,GACX,YAAY,GACZ,SAAS,GACT,OAAO,GACP,MAAM,GACN,QAAQ,GACR,KAAK,GACL,QAAQ,GACR,SAAS,GACT,MAAM,GACN,MAAM,CAAC;AAEX;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AA4DD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB,UAAW,gBAAgB,ylCAaxD,CAAC;AAEJ;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,aAAa,UAAW,gBAAgB,sIACmB,CAAC;AAEzE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,qBAAqB,GAAG,UAAU,GAAG,SAAS,sCAC3C,CAAC;AAEzC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,eAAgB,qBAAqB,SAAS,gBAAgB,YAcnB,CAAC"}
|
|
@@ -144,9 +144,11 @@ then tidiness.
|
|
|
144
144
|
- 42 test files on `node:test` (S43);
|
|
145
145
|
- `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
distilled
|
|
149
|
-
|
|
147
|
+
The generated SDK ships as `@homeflare/distilled-openbao` (interim alias for
|
|
148
|
+
`@distilled.cloud/openbao`). `Bao.Policy` and `Bao.AuthRole` use it for CRUD and rename collision
|
|
149
|
+
reads, sharing the existing eight-request gate, timeout and agent transport. Other
|
|
150
|
+
Bao families still use the raw client. Auth/login envelope decoding and house plugin
|
|
151
|
+
schemas remain separate SDK work before those families can migrate.
|
|
150
152
|
|
|
151
153
|
## Remaining family findings
|
|
152
154
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.6",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -122,9 +122,10 @@
|
|
|
122
122
|
"@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
|
|
123
123
|
"@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
|
|
124
124
|
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
|
|
125
|
+
"@distilled.cloud/openbao": "npm:@homeflare/distilled-openbao@0.2.2",
|
|
125
126
|
"@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.2.0",
|
|
126
127
|
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
|
|
127
|
-
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.
|
|
128
|
+
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.2",
|
|
128
129
|
"@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
|
|
129
130
|
"@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.2.0"
|
|
130
131
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory a read may stat.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A `creating` ROW CAN HAVE NO PATH. With `path: Output.interpolate`, Apply commits the row
|
|
5
|
+
* while the path is still an Output, so the next plan's recovery read is asked with `path`
|
|
6
|
+
* missing. `runner.stat` of that value throws `path must be a string` and the plan never
|
|
7
|
+
* finishes — the same brick `binary-read.ts` already closes for `Release.Binary`. A recovery
|
|
8
|
+
* (`recovering`) returns undefined and the create is re-driven. A probe with a missing path
|
|
9
|
+
* still fails, as a message, before any host call.
|
|
10
|
+
*/
|
|
11
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
12
|
+
import { type HostDirectoryAttributes, readDirectory } from './directory-lifecycle.ts';
|
|
13
|
+
|
|
14
|
+
export const readInterruptedDirectory = (
|
|
15
|
+
runner: HostRunner,
|
|
16
|
+
path: unknown,
|
|
17
|
+
recovering: boolean,
|
|
18
|
+
): Promise<HostDirectoryAttributes | undefined> => {
|
|
19
|
+
if (typeof path !== 'string') {
|
|
20
|
+
if (recovering) return Promise.resolve(undefined);
|
|
21
|
+
return Promise.reject(new Error('Host.Directory path must be a string'));
|
|
22
|
+
}
|
|
23
|
+
return readDirectory(runner, path);
|
|
24
|
+
};
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readDirectory,
|
|
11
11
|
reconcileDirectory,
|
|
12
12
|
} from './directory-lifecycle.ts';
|
|
13
|
+
import { readInterruptedDirectory } from './directory-read.ts';
|
|
13
14
|
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
14
15
|
|
|
15
16
|
const host = () =>
|
|
@@ -154,3 +155,22 @@ describe('validation', () => {
|
|
|
154
155
|
);
|
|
155
156
|
});
|
|
156
157
|
});
|
|
158
|
+
|
|
159
|
+
describe('an interrupted create', () => {
|
|
160
|
+
test('a missing path is not stat-ed, and a probe still fails', async () => {
|
|
161
|
+
const fake = host();
|
|
162
|
+
let stats = 0;
|
|
163
|
+
const runner = {
|
|
164
|
+
...fake.runner,
|
|
165
|
+
stat: async (path: string) => {
|
|
166
|
+
stats += 1;
|
|
167
|
+
return fake.runner.stat(path);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
expect(await readInterruptedDirectory(runner, undefined, true)).toBeUndefined();
|
|
171
|
+
await expect(readInterruptedDirectory(runner, undefined, false)).rejects.toThrow(
|
|
172
|
+
'path must be a string',
|
|
173
|
+
);
|
|
174
|
+
expect(stats).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/linux/directory.ts
CHANGED
|
@@ -18,13 +18,10 @@ import { lift, resolvedString } from '../launchd/host-effect.ts';
|
|
|
18
18
|
import { HostRunnerService } from '../launchd/runner.ts';
|
|
19
19
|
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
20
20
|
import { noteUnfinished } from '../ownership/resume.ts';
|
|
21
|
+
import { recordedGeneration } from '../ownership/rows.ts';
|
|
21
22
|
import type { HostDirectoryAttributes, HostDirectoryProps } from './directory-lifecycle.ts';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
diffDirectory,
|
|
25
|
-
readDirectory,
|
|
26
|
-
reconcileDirectory,
|
|
27
|
-
} from './directory-lifecycle.ts';
|
|
23
|
+
import { deleteDirectory, diffDirectory, reconcileDirectory } from './directory-lifecycle.ts';
|
|
24
|
+
import { readInterruptedDirectory } from './directory-read.ts';
|
|
28
25
|
|
|
29
26
|
export type { HostDirectoryAttributes, HostDirectoryProps } from './directory-lifecycle.ts';
|
|
30
27
|
|
|
@@ -44,11 +41,24 @@ export const HostDirectoryProvider = () =>
|
|
|
44
41
|
return HostDirectory.Provider.of({
|
|
45
42
|
list: () => Effect.succeed([]),
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
// ⛔ Recovery of a `creating` row whose path was still an Output never stats (directory-read.ts).
|
|
45
|
+
read: ({ fqn, instanceId, olds, output }) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const recovering =
|
|
48
|
+
typeof olds.path === 'string'
|
|
49
|
+
? false
|
|
50
|
+
: (yield* recordedGeneration(fqn, instanceId)) !== undefined;
|
|
51
|
+
if (recovering) {
|
|
52
|
+
yield* Effect.logWarning(
|
|
53
|
+
`${fqn}: the interrupted create's row has no directory path; nothing is recovered, ` +
|
|
54
|
+
'and the create is re-driven with the declaration as it is now',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return yield* lift(async () => {
|
|
58
|
+
const found = await readInterruptedDirectory(runner, olds.path, recovering);
|
|
59
|
+
if (found === undefined) return undefined;
|
|
60
|
+
return output === undefined ? Unowned(found) : found;
|
|
61
|
+
});
|
|
52
62
|
}),
|
|
53
63
|
|
|
54
64
|
diff: ({ instanceId, news, output }) => {
|
|
@@ -34,6 +34,7 @@ import type { LitellmOpContext } from '@distilled.cloud/litellm/Protocol';
|
|
|
34
34
|
import type { ConfigError } from '@distilled.cloud/litellm/Errors';
|
|
35
35
|
import * as Effect from 'effect/Effect';
|
|
36
36
|
import * as Semaphore from 'effect/Semaphore';
|
|
37
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
37
38
|
|
|
38
39
|
export type { LitellmOpContext } from '@distilled.cloud/litellm/Protocol';
|
|
39
40
|
export type LitellmRequirements = LitellmOpContext;
|
|
@@ -67,6 +68,18 @@ const semaphoreFor = (baseUrl: string): Effect.Effect<Semaphore.Semaphore> =>
|
|
|
67
68
|
* `Credentials`'s own type — so `E` (the wrapped operation's error) already contains it, and
|
|
68
69
|
* this widening is a genuine no-op for callers, not dead type-state left over from the revert.
|
|
69
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* ⛔ THE STACK'S AMBIENT `HttpClient` MAY BE CADDY'S. `caddyProviders()` provideMerges a client
|
|
73
|
+
* that dials the admin listener and ignores the request host (`caddy-http-client.ts`). Measured
|
|
74
|
+
* 2026-09-25: that client answers `GET /config/pass_through_endpoint` with HTTP 200 `null`
|
|
75
|
+
* (Caddy's empty-config body). The decoded value then has no `endpoints`, and `locate` throws
|
|
76
|
+
* `rows.find` of undefined. FetchHttpClient uses the URL the LiteLLM protocol built. An outer
|
|
77
|
+
* `Fetch` service still wins inside that client, which is how the fake proxy stays the test
|
|
78
|
+
* transport.
|
|
79
|
+
*/
|
|
80
|
+
const throughFetch = <A, E, R>(io: Effect.Effect<A, E, R>) =>
|
|
81
|
+
io.pipe(Effect.provide(FetchHttpClient.layer));
|
|
82
|
+
|
|
70
83
|
const mutate = <A, E>(
|
|
71
84
|
io: Effect.Effect<A, E, LitellmOpContext>,
|
|
72
85
|
): Effect.Effect<A, E | ConfigError, LitellmOpContext> =>
|
|
@@ -74,18 +87,32 @@ const mutate = <A, E>(
|
|
|
74
87
|
const resolve = yield* Credentials;
|
|
75
88
|
const creds = yield* resolve;
|
|
76
89
|
const semaphore = yield* semaphoreFor(creds.apiBaseUrl);
|
|
77
|
-
return yield* semaphore.withPermits(1)(io);
|
|
90
|
+
return yield* semaphore.withPermits(1)(throughFetch(io));
|
|
78
91
|
});
|
|
79
92
|
|
|
93
|
+
const endpointsOf = (
|
|
94
|
+
response: misc.PassThroughEndpointResponse,
|
|
95
|
+
): Effect.Effect<readonly misc.PassThroughGenericEndpoint[]> => {
|
|
96
|
+
const rows = response.endpoints;
|
|
97
|
+
if (!Array.isArray(rows)) {
|
|
98
|
+
return Effect.die(
|
|
99
|
+
new Error(
|
|
100
|
+
'GET /config/pass_through_endpoint decoded no endpoints array. The call reached something other than the LiteLLM proxy.',
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return Effect.succeed(rows);
|
|
105
|
+
};
|
|
106
|
+
|
|
80
107
|
/** GET /config/pass_through_endpoint — every row, config-file and DB alike. Not under the semaphore: reads don't race the field. */
|
|
81
108
|
export const listPassThroughEndpoints = (): Effect.Effect<
|
|
82
109
|
readonly misc.PassThroughGenericEndpoint[],
|
|
83
110
|
misc.GetPassThroughEndpointsConfigPassThroughEndpointGetError,
|
|
84
111
|
LitellmOpContext
|
|
85
112
|
> =>
|
|
86
|
-
misc
|
|
87
|
-
.
|
|
88
|
-
|
|
113
|
+
throughFetch(misc.getPassThroughEndpointsConfigPassThroughEndpointGet({})).pipe(
|
|
114
|
+
Effect.flatMap(endpointsOf),
|
|
115
|
+
);
|
|
89
116
|
|
|
90
117
|
/** POST /config/pass_through_endpoint. `body` carries `id` — see pass-through-form.ts. */
|
|
91
118
|
export const createPassThroughEndpoint = (
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stack that also provides Caddy's admin `HttpClient` must not send LiteLLM's list there.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ MEASURED 2026-09-25. `caddyProviders()` provideMerges a client that dials `127.0.0.1:2019`
|
|
5
|
+
* and ignores the request host. `GET /config/pass_through_endpoint` on that listener is HTTP
|
|
6
|
+
* 200 `null`. `listPassThroughEndpoints` then has no `endpoints` array and `locate` throws.
|
|
7
|
+
* The call provides `FetchHttpClient`, which still reads an outer `Fetch` — this fake.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { credentials } from '@distilled.cloud/litellm/Credentials';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
14
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
15
|
+
import { FAKE_BASE, startFakeLitellm } from './fake-litellm.ts';
|
|
16
|
+
import { listPassThroughEndpoints } from './operations.ts';
|
|
17
|
+
|
|
18
|
+
const MASTER_KEY = 'sk-test-master';
|
|
19
|
+
|
|
20
|
+
describe('the proxy list', () => {
|
|
21
|
+
test('uses Fetch, not an ambient client that answers null', async () => {
|
|
22
|
+
const fake = startFakeLitellm({
|
|
23
|
+
masterKey: MASTER_KEY,
|
|
24
|
+
seed: [{ auth: true, id: 'live-1', path: '/bria', target: 'https://api.bria.ai' }],
|
|
25
|
+
});
|
|
26
|
+
const ambient = HttpClient.make(() => Effect.die(new Error('ambient client')));
|
|
27
|
+
const rows = await Effect.runPromise(
|
|
28
|
+
listPassThroughEndpoints().pipe(
|
|
29
|
+
Effect.provideService(HttpClient.HttpClient, ambient),
|
|
30
|
+
Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fake.fetch)),
|
|
31
|
+
Effect.provide(credentials({ apiKey: MASTER_KEY, baseUrl: FAKE_BASE })),
|
|
32
|
+
Effect.provide(FetchHttpClient.layer),
|
|
33
|
+
) as Effect.Effect<readonly { id?: string | null }[], unknown, never>,
|
|
34
|
+
);
|
|
35
|
+
expect(rows.map((row) => row.id)).toEqual(['live-1']);
|
|
36
|
+
expect(fake.requests().map((request) => request.path)).toEqual([
|
|
37
|
+
'/config/pass_through_endpoint',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bao.AuthRole's secret_id TTL at the 90-day length the per-host AppRoles use.
|
|
3
3
|
*
|
|
4
|
-
* ★
|
|
4
|
+
* ★ OPENBAO ACCEPTS `2160h`. MEASURED 2026-09-21 with a Go probe of
|
|
5
5
|
* sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
|
|
6
6
|
* TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
|
|
7
7
|
* read hands back that integer, which attributesOf renders as `90d` — the same role.
|
|
8
|
+
* The SDK now sends the equivalent seconds directly instead of the former CLI duration string.
|
|
8
9
|
* (What the diff calls a rename is pinned in rename-families.test.ts, `foldName`.)
|
|
9
10
|
*/
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import { type BaoAuthRoleProps, attributesOf, matches
|
|
11
|
+
import { describe, expect, it } from 'bun:test';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import { type BaoAuthRoleProps, attributesOf, matches } from './auth-role-form.ts';
|
|
14
|
+
import { authRoleRequest } from './auth-role-wire.ts';
|
|
13
15
|
|
|
14
16
|
const PROPS: BaoAuthRoleProps = {
|
|
15
17
|
bindSecretId: true,
|
|
@@ -22,8 +24,8 @@ const PROPS: BaoAuthRoleProps = {
|
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
describe('auth role secret_id TTL', () => {
|
|
25
|
-
it('writes 2160h as
|
|
26
|
-
|
|
27
|
+
it('writes 2160h as the equivalent SDK seconds', async () => {
|
|
28
|
+
expect((await Effect.runPromise(authRoleRequest(PROPS))).secret_id_ttl).toBe(7776000);
|
|
27
29
|
});
|
|
28
30
|
|
|
29
31
|
it('reads 7776000 seconds back as the same role', () => {
|
|
@@ -36,8 +38,8 @@ describe('auth role secret_id TTL', () => {
|
|
|
36
38
|
token_ttl: 900,
|
|
37
39
|
};
|
|
38
40
|
const attributes = attributesOf(PROPS, live);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
expect(attributes.secretIdTtl).toBe('90d');
|
|
42
|
+
expect(matches(attributes, PROPS)).toBe(true);
|
|
43
|
+
expect(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS)).toBe(false);
|
|
42
44
|
});
|
|
43
45
|
});
|
|
@@ -70,25 +70,6 @@ export const attributesOf = (
|
|
|
70
70
|
|
|
71
71
|
export const readPath = (name: string) => `auth/approle/role/${name}`;
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
* The body for `PUT auth/approle/role/<name>`.
|
|
75
|
-
*
|
|
76
|
-
* ★ ALL STRINGS, EXACTLY THE `k=v` PAIRS `bao write` USED TO SEND — see `baoWrite` in bao-http.ts.
|
|
77
|
-
*/
|
|
78
|
-
export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
|
|
79
|
-
const body: Record<string, string> = {
|
|
80
|
-
token_policies: props.tokenPolicies.join(','),
|
|
81
|
-
token_ttl: props.tokenTtl,
|
|
82
|
-
token_max_ttl: props.tokenMaxTtl,
|
|
83
|
-
secret_id_ttl: props.secretIdTtl,
|
|
84
|
-
};
|
|
85
|
-
if (props.bindSecretId !== undefined) body['bind_secret_id'] = String(props.bindSecretId);
|
|
86
|
-
if (props.secretIdNumUses !== undefined) {
|
|
87
|
-
body['secret_id_num_uses'] = String(props.secretIdNumUses);
|
|
88
|
-
}
|
|
89
|
-
return body;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
73
|
const samePolicies = (want: readonly string[], have: readonly string[]) =>
|
|
93
74
|
want.length === have.length && want.every((policy, index) => policy === have[index]);
|
|
94
75
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readPath } from './auth-role-form.ts';
|
|
2
|
+
import { authRoleExists } from './auth-role-wire.ts';
|
|
3
|
+
import { declaredString, judgeMoveWith } from './rename.ts';
|
|
4
|
+
import { foldName, nameIdentity } from './rename-identity.ts';
|
|
5
|
+
|
|
6
|
+
export const authRoleIdentity = nameIdentity<{ readonly name: string }>(
|
|
7
|
+
'Bao.AuthRole',
|
|
8
|
+
readPath,
|
|
9
|
+
foldName,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** All occupied-name reads share the SDK's typed absence and refusal behavior. */
|
|
13
|
+
export const judgeAuthRoleRename = (
|
|
14
|
+
olds: unknown,
|
|
15
|
+
news: unknown,
|
|
16
|
+
output: { readonly name: string } | undefined,
|
|
17
|
+
) =>
|
|
18
|
+
judgeMoveWith(
|
|
19
|
+
'Bao.AuthRole',
|
|
20
|
+
output === undefined ? declaredString(olds, 'name') : output.name,
|
|
21
|
+
declaredString(news, 'name'),
|
|
22
|
+
authRoleExists,
|
|
23
|
+
foldName,
|
|
24
|
+
);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** AppRole metadata through the real SDK and Alchemy's upstream test harness; no real vault. */
|
|
2
|
+
import { afterAll, beforeEach, expect } from 'bun:test';
|
|
3
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
4
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
5
|
+
import * as Test from 'alchemy/Test/Bun';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Layer from 'effect/Layer';
|
|
8
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
9
|
+
import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
10
|
+
import { BaoEnv } from './bao-http.ts';
|
|
11
|
+
import { type Reply, fakeBao } from './fake-bao.ts';
|
|
12
|
+
import { wireRoles } from './fake-engines-roles.ts';
|
|
13
|
+
import { writesOf } from './fake-stack.ts';
|
|
14
|
+
|
|
15
|
+
const store = wireRoles();
|
|
16
|
+
let refused: Reply | undefined;
|
|
17
|
+
const bao = fakeBao((seen) =>
|
|
18
|
+
seen.method === 'GET' && refused !== undefined ? refused : store(seen),
|
|
19
|
+
);
|
|
20
|
+
afterAll(() => bao.stop());
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
store.live.clear();
|
|
23
|
+
bao.seen.length = 0;
|
|
24
|
+
refused = undefined;
|
|
25
|
+
});
|
|
26
|
+
const { test } = Test.make({
|
|
27
|
+
adopt: true,
|
|
28
|
+
dev: false,
|
|
29
|
+
sidecar: false,
|
|
30
|
+
stage: 'test',
|
|
31
|
+
providers: Layer.mergeAll(
|
|
32
|
+
BaoAuthRoleProvider(),
|
|
33
|
+
FetchHttpClient.layer,
|
|
34
|
+
Layer.succeed(BaoEnv, { BAO_ADDR: bao.address }),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
const props = {
|
|
38
|
+
name: 'host-cert',
|
|
39
|
+
tokenPolicies: ['host-cert'],
|
|
40
|
+
tokenTtl: '15m',
|
|
41
|
+
tokenMaxTtl: '1h',
|
|
42
|
+
secretIdTtl: '2160h',
|
|
43
|
+
};
|
|
44
|
+
const path = 'auth/approle/role/host-cert';
|
|
45
|
+
const existing = () =>
|
|
46
|
+
store.live.set(path, {
|
|
47
|
+
bind_secret_id: false,
|
|
48
|
+
secret_id_num_uses: 7,
|
|
49
|
+
secret_id_ttl: 7776000,
|
|
50
|
+
token_max_ttl: 3600,
|
|
51
|
+
token_policies: ['host-cert'],
|
|
52
|
+
token_ttl: 900,
|
|
53
|
+
token_period: 1800,
|
|
54
|
+
secret_id_bound_cidrs: ['192.0.2.0/24'],
|
|
55
|
+
});
|
|
56
|
+
const role = (tokenTtl = '15m') => BaoAuthRole('role', { ...props, tokenTtl });
|
|
57
|
+
const actions = (plan: { resources: Record<string, { action: string }> }) =>
|
|
58
|
+
Object.values(plan.resources).map((row) => row.action);
|
|
59
|
+
|
|
60
|
+
test.provider('existing metadata adopts without writes, then plans and deploys noop', (scratch) =>
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
existing();
|
|
63
|
+
yield* scratch.deploy(role());
|
|
64
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
65
|
+
expect(actions(yield* scratch.plan(role()))).toEqual(['noop']);
|
|
66
|
+
yield* scratch.deploy(role());
|
|
67
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
test.provider('drift writes one SDK POST and preserves omitted and unmanaged settings', (scratch) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
existing();
|
|
74
|
+
yield* scratch.deploy(role());
|
|
75
|
+
yield* scratch.deploy(role('30m'));
|
|
76
|
+
expect(writesOf(bao.seen)).toEqual(['POST /v1/auth/approle/role/host-cert']);
|
|
77
|
+
expect(store.live.get(path)).toMatchObject({
|
|
78
|
+
bind_secret_id: false,
|
|
79
|
+
secret_id_num_uses: 7,
|
|
80
|
+
token_period: 1800,
|
|
81
|
+
token_ttl: 1800,
|
|
82
|
+
secret_id_bound_cidrs: ['192.0.2.0/24'],
|
|
83
|
+
});
|
|
84
|
+
expect(actions(yield* scratch.plan(role('30m')))).toEqual(['noop']);
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
test.provider(
|
|
89
|
+
'typed absence creates metadata with server defaults then settles to noop',
|
|
90
|
+
(scratch) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
yield* scratch.deploy(role());
|
|
93
|
+
expect(writesOf(bao.seen)).toEqual(['POST /v1/auth/approle/role/host-cert']);
|
|
94
|
+
expect(store.live.get(path)).toMatchObject({ bind_secret_id: true, secret_id_num_uses: 0 });
|
|
95
|
+
expect(actions(yield* scratch.plan(role()))).toEqual(['noop']);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
test.provider('occupied case-folded rename refuses before writes or deletes', (scratch) =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
existing();
|
|
102
|
+
yield* scratch.deploy(role().pipe(RemovalPolicy.destroy()));
|
|
103
|
+
store.live.set('auth/approle/role/taken', { ...store.live.get(path) });
|
|
104
|
+
bao.seen.length = 0;
|
|
105
|
+
const result = yield* Effect.exit(
|
|
106
|
+
scratch.plan(BaoAuthRole('role', { ...props, name: 'TAKEN' })),
|
|
107
|
+
);
|
|
108
|
+
expect(result._tag).toBe('Failure');
|
|
109
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
test.provider('existing unowned role refuses without adoption', (scratch) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
existing();
|
|
116
|
+
expect((yield* Effect.exit(scratch.plan(role().pipe(adopt(false)))))._tag).toBe('Failure');
|
|
117
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
test.provider('destroy deletes only the recorded role and an empty redeploy is inert', (scratch) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
yield* scratch.deploy(role().pipe(RemovalPolicy.destroy()));
|
|
124
|
+
bao.seen.length = 0;
|
|
125
|
+
yield* scratch.deploy(Effect.void);
|
|
126
|
+
expect(writesOf(bao.seen)).toEqual(['DELETE /v1/auth/approle/role/host-cert']);
|
|
127
|
+
expect(store.live.has(path)).toBe(false);
|
|
128
|
+
bao.seen.length = 0;
|
|
129
|
+
yield* scratch.deploy(Effect.void);
|
|
130
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
test.provider('default retain leaves removed role metadata untouched', (scratch) =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
yield* scratch.deploy(role());
|
|
137
|
+
bao.seen.length = 0;
|
|
138
|
+
yield* scratch.deploy(Effect.void);
|
|
139
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
140
|
+
expect(store.live.has(path)).toBe(true);
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
for (const status of [200, 403]) {
|
|
145
|
+
test.provider(`a malformed or denied ${status} read never becomes create or noop`, (scratch) =>
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
existing();
|
|
148
|
+
yield* scratch.deploy(role());
|
|
149
|
+
refused = { status, json: { data: {}, errors: ['refused'] } };
|
|
150
|
+
expect((yield* Effect.exit(scratch.plan(role())))._tag).toBe('Failure');
|
|
151
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
152
|
+
refused = undefined;
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|