@homeflare/alchemy 0.6.0 → 0.8.0
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/README.md +74 -41
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +51 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +16 -12
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nine batch-2 families, renamed through Alchemy's own plan and apply (fake-stack.ts), one row
|
|
3
|
+
* each (fake-families.ts). 🔴 Before rename-identity.ts, MEASURED 2026-09-21 against these fakes:
|
|
4
|
+
* · a swap under destroy was a GREEN deploy that deleted both objects, for Bao.AuthRole,
|
|
5
|
+
* Bao.PkiRole, Bao.JwtRole, Bao.KubernetesRole, Bao.SshRole and Bao.Plugin, and a shift left
|
|
6
|
+
* only `c`. Bao.JwtAuthConfig wrote each config over the other (it has no delete), and
|
|
7
|
+
* Bao.MfaLoginEnforcement did the same, then stopped on its two refused deletes;
|
|
8
|
+
* · a rename landing with another pending Output planned `update` for all nine and left the old
|
|
9
|
+
* object live and unrecorded; for Bao.MfaTotpMethod that wrote a second method;
|
|
10
|
+
* · with the identity itself pending, all nine wrote the new object and kept the old.
|
|
11
|
+
* What each family's identity folds or defaults is pinned in rename-identity.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import * as Output from 'alchemy/Output';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import type { Estate } from './fake-engines-roles.ts';
|
|
18
|
+
import { FAMILIES, type Family, pairOf, upstream, withEstate } from './fake-families.ts';
|
|
19
|
+
import { writesOf } from './fake-stack.ts';
|
|
20
|
+
|
|
21
|
+
const OCCUPIED = /would land on an object that already exists/;
|
|
22
|
+
/** Bao.MfaTotpMethod never replaces: every rename is refused (mfa-totp.ts). */
|
|
23
|
+
const STRANDS = /would strand every enrolled secret/;
|
|
24
|
+
const isTotp = (row: Family) => row.family === 'Bao.MfaTotpMethod';
|
|
25
|
+
|
|
26
|
+
const snapshot = (estate: Estate) => [
|
|
27
|
+
new Map(estate.roles.live),
|
|
28
|
+
new Map(estate.plugins.live),
|
|
29
|
+
new Map(estate.totp.live),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Another prop of the row pending: the upstream's `tokenTtl` changes in the same deploy. */
|
|
33
|
+
const pendingKnob = (row: Family) => (ttl: string, name: string) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const up = yield* upstream(ttl);
|
|
36
|
+
yield* row.declare('Subject', name, up.tokenTtl);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** The identity itself pending: `r-15m`, then `r-30m`. */
|
|
40
|
+
const pendingName = (row: Family) => (ttl: string) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const up = yield* upstream(ttl);
|
|
43
|
+
yield* row.declare('Subject', Output.interpolate`r-${up.tokenTtl}`, '15m');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const row of FAMILIES) {
|
|
47
|
+
describe(`${row.family}: a rename`, () => {
|
|
48
|
+
const pair = pairOf(row);
|
|
49
|
+
const refusal = isTotp(row) ? STRANDS : OCCUPIED;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ★ THE CONTROL: the identity a generation RECORDS must be the one its props DECLARE. If the two
|
|
53
|
+
* spellings drift apart (a default mount, a version, a trailing `/`), every later plan reads as
|
|
54
|
+
* a move onto the resource's own object and fails, or replaces it, under destroy, onto itself.
|
|
55
|
+
*/
|
|
56
|
+
it('redeployed unchanged is a noop that writes nothing', async () => {
|
|
57
|
+
await withEstate(async (stack, _estate, seen) => {
|
|
58
|
+
await stack.deploy(pair('a', 'b'));
|
|
59
|
+
seen.length = 0;
|
|
60
|
+
assert.deepEqual(await stack.deploy(pair('a', 'b')), { X: 'noop', Y: 'noop' });
|
|
61
|
+
assert.deepEqual(writesOf(seen), []);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('onto a name its sibling holds (a swap, under destroy) writes and deletes nothing', async () => {
|
|
66
|
+
await withEstate(async (stack, estate, seen) => {
|
|
67
|
+
await stack.deploy(pair('a', 'b'));
|
|
68
|
+
const before = snapshot(estate);
|
|
69
|
+
seen.length = 0;
|
|
70
|
+
await assert.rejects(stack.deploy(pair('b', 'a')), refusal);
|
|
71
|
+
assert.deepEqual(writesOf(seen), []);
|
|
72
|
+
assert.deepEqual(snapshot(estate), before);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('onto a name still held while that one moves on (a shift) writes nothing', async () => {
|
|
77
|
+
await withEstate(async (stack, estate, seen) => {
|
|
78
|
+
await stack.deploy(pair('a', 'b'));
|
|
79
|
+
seen.length = 0;
|
|
80
|
+
await assert.rejects(stack.deploy(pair('b', 'c')), refusal);
|
|
81
|
+
assert.deepEqual(writesOf(seen), []);
|
|
82
|
+
assert.ok(row.has(estate, 'a') && row.has(estate, 'b') && !row.has(estate, 'c'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('that lands with another prop pending is judged at plan, not planned as update', async () => {
|
|
87
|
+
await withEstate(async (stack, estate, seen) => {
|
|
88
|
+
const body = pendingKnob(row);
|
|
89
|
+
await stack.deploy(body('15m', 'a'));
|
|
90
|
+
if (isTotp(row)) {
|
|
91
|
+
seen.length = 0;
|
|
92
|
+
await assert.rejects(stack.deploy(body('30m', 'b')), STRANDS);
|
|
93
|
+
// ★ At plan: not even the upstream's update is applied.
|
|
94
|
+
assert.deepEqual(writesOf(seen), []);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
assert.deepEqual(await stack.deploy(body('30m', 'b')), {
|
|
98
|
+
Subject: 'replace',
|
|
99
|
+
Upstream: 'update',
|
|
100
|
+
});
|
|
101
|
+
// ★ The default retain keeps the old generation; the new one is written.
|
|
102
|
+
assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('to a name not known until apply is refused before any write, then judged', async () => {
|
|
107
|
+
await withEstate(async (stack, estate) => {
|
|
108
|
+
const body = pendingName(row);
|
|
109
|
+
await stack.deploy(body('15m'));
|
|
110
|
+
const moved = isTotp(row) ? /renaming from `r-15m`/ : /identity moved from \S*r-15m\S* to/;
|
|
111
|
+
await assert.rejects(stack.deploy(body('30m')), moved);
|
|
112
|
+
assert.ok(!row.has(estate, 'r-30m'));
|
|
113
|
+
if (isTotp(row)) {
|
|
114
|
+
await assert.rejects(stack.deploy(body('30m')), STRANDS);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
assert.deepEqual(await stack.deploy(body('30m')), { Subject: 'replace', Upstream: 'noop' });
|
|
118
|
+
assert.ok(row.has(estate, 'r-30m'));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each family counts as "the same object": the case the server folds, the mount an absent prop
|
|
3
|
+
* means, a trailing `/`, and a plugin's version. rename-identity.ts spells an identity twice, as the
|
|
4
|
+
* diff's props DECLARE it and as a generation's attributes RECORD it, and the two must agree, or a
|
|
5
|
+
* plan reads a resource's own object as a move onto somebody else's. Run through Alchemy's own plan
|
|
6
|
+
* and apply (fake-stack.ts) against fake-engines-roles.ts.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
13
|
+
import { rowOf, withEstate } from './fake-families.ts';
|
|
14
|
+
import { writesOf } from './fake-stack.ts';
|
|
15
|
+
import { BaoJwtAuthConfig } from './jwt-config.ts';
|
|
16
|
+
import { BaoPkiRole } from './pki-role.ts';
|
|
17
|
+
import { BaoPlugin } from './plugin.ts';
|
|
18
|
+
import { foldName } from './rename-identity.ts';
|
|
19
|
+
|
|
20
|
+
const OCCUPIED = /would land on an object that already exists/;
|
|
21
|
+
|
|
22
|
+
describe('a change of case the server folds away is not a rename', () => {
|
|
23
|
+
it('foldName lowercases the name segment only; a mount path keeps its case', () => {
|
|
24
|
+
assert.equal(foldName('Host-Cert'), 'host-cert');
|
|
25
|
+
assert.equal(foldName('auth/K8s/role/App'), 'auth/K8s/role/app');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('Bao.AuthRole: `Host-A` after `host-a` under destroy is a noop, nothing deleted', async () => {
|
|
29
|
+
const row = rowOf('Bao.AuthRole');
|
|
30
|
+
await withEstate(async (stack, estate, seen) => {
|
|
31
|
+
await stack.deploy(row.declare('Role', 'host-a', '15m', RemovalPolicy.destroy));
|
|
32
|
+
seen.length = 0;
|
|
33
|
+
const plan = await stack.deploy(row.declare('Role', 'Host-A', '15m', RemovalPolicy.destroy));
|
|
34
|
+
assert.deepEqual(plan, { Role: 'noop' });
|
|
35
|
+
assert.deepEqual(writesOf(seen), []);
|
|
36
|
+
assert.ok(row.has(estate, 'host-a'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ★ Both refuse an upper-case name (`problems`), so the change is refused either way. Folded, it is
|
|
42
|
+
* refused for what it is; compared exactly, the plan read `role/App`, the server folded that to
|
|
43
|
+
* the role's own `role/app`, and the move was refused as landing on an object that exists.
|
|
44
|
+
*/
|
|
45
|
+
for (const family of ['Bao.JwtRole', 'Bao.KubernetesRole']) {
|
|
46
|
+
it(`${family}: \`App\` after \`app\` is the same role, refused only for its case`, async () => {
|
|
47
|
+
const row = rowOf(family);
|
|
48
|
+
await withEstate(async (stack, estate, seen) => {
|
|
49
|
+
await stack.deploy(row.declare('Role', 'app', '15m', RemovalPolicy.destroy));
|
|
50
|
+
seen.length = 0;
|
|
51
|
+
const again = stack.deploy(row.declare('Role', 'App', '15m', RemovalPolicy.destroy));
|
|
52
|
+
await assert.rejects(again, /must be lower case/);
|
|
53
|
+
assert.deepEqual(writesOf(seen), []);
|
|
54
|
+
assert.ok(row.has(estate, 'app'));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('the mount an absent prop means, and a trailing `/`, are the same mount', () => {
|
|
61
|
+
it('Bao.JwtAuthConfig with no `mount` redeploys as a noop: the default is `jwt`', async () => {
|
|
62
|
+
const config = () =>
|
|
63
|
+
BaoJwtAuthConfig('Config', {
|
|
64
|
+
boundIssuer: 'https://issuer.example.com',
|
|
65
|
+
jwksUrl: 'https://issuer.example.com/keys',
|
|
66
|
+
});
|
|
67
|
+
await withEstate(async (stack, estate, seen) => {
|
|
68
|
+
await stack.deploy(config());
|
|
69
|
+
seen.length = 0;
|
|
70
|
+
assert.deepEqual(await stack.deploy(config()), { Config: 'noop' });
|
|
71
|
+
assert.deepEqual(writesOf(seen), []);
|
|
72
|
+
assert.ok(estate.roles.live.has('auth/jwt/config'));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('Bao.PkiRole: `pki/` after the default mount is the same role, a noop', async () => {
|
|
77
|
+
const role = (mount?: string) =>
|
|
78
|
+
BaoPkiRole('Role', {
|
|
79
|
+
allowedDomains: ['example.com'],
|
|
80
|
+
maxTtl: '8760h',
|
|
81
|
+
name: 'web',
|
|
82
|
+
ttl: '15m',
|
|
83
|
+
...(mount === undefined ? {} : { mount }),
|
|
84
|
+
}).pipe(RemovalPolicy.destroy());
|
|
85
|
+
await withEstate(async (stack, estate, seen) => {
|
|
86
|
+
await stack.deploy(role());
|
|
87
|
+
seen.length = 0;
|
|
88
|
+
assert.deepEqual(await stack.deploy(role('pki/')), { Role: 'noop' });
|
|
89
|
+
assert.deepEqual(writesOf(seen), []);
|
|
90
|
+
assert.ok(estate.roles.live.has('pki/roles/web'));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Bao.Plugin: the version is part of the catalog key', () => {
|
|
96
|
+
const CATALOG = 'sys/plugins/catalog/secret/example';
|
|
97
|
+
const plugin = (version: string) =>
|
|
98
|
+
BaoPlugin('Plugin', {
|
|
99
|
+
command: 'plugin-example',
|
|
100
|
+
name: 'example',
|
|
101
|
+
sha256: 'a'.repeat(64),
|
|
102
|
+
type: 'secret',
|
|
103
|
+
version,
|
|
104
|
+
}).pipe(RemovalPolicy.destroy());
|
|
105
|
+
|
|
106
|
+
it('a bump registers the new version, then deregisters the old one', async () => {
|
|
107
|
+
await withEstate(async (stack, estate, seen) => {
|
|
108
|
+
await stack.deploy(plugin('v1.0.0'));
|
|
109
|
+
seen.length = 0;
|
|
110
|
+
assert.deepEqual(await stack.deploy(plugin('v1.1.0')), { Plugin: 'replace' });
|
|
111
|
+
assert.deepEqual(writesOf(seen), [
|
|
112
|
+
`PUT /v1/${CATALOG}`,
|
|
113
|
+
`DELETE /v1/${CATALOG}?version=v1.0.0`,
|
|
114
|
+
]);
|
|
115
|
+
assert.deepEqual([...estate.plugins.live.keys()], [`${CATALOG}?version=v1.1.0`]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('a bump onto a version already registered by hand fails the plan', async () => {
|
|
120
|
+
await withEstate(async (stack, estate, seen) => {
|
|
121
|
+
await stack.deploy(plugin('v1.0.0'));
|
|
122
|
+
const byHand = { builtin: false, command: 'plugin-example', version: 'v1.1.0' };
|
|
123
|
+
estate.plugins.live.set(`${CATALOG}?version=v1.1.0`, byHand);
|
|
124
|
+
seen.length = 0;
|
|
125
|
+
await assert.rejects(stack.deploy(plugin('v1.1.0')), OCCUPIED);
|
|
126
|
+
assert.deepEqual(writesOf(seen), []);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rename guard every Bao.* family whose identity is a name, a mount, or a catalog key shares:
|
|
3
|
+
* one `Identity` per family, and two steps built from rename.ts, `judgeRename` at the top of `diff`
|
|
4
|
+
* and `guardRename` at the top of `reconcile`. REPLACE.md has the audit behind both.
|
|
5
|
+
*
|
|
6
|
+
* ★ ONE DESCRIPTION OF "WHICH OBJECT", NOT TWELVE COPIES OF THE CHECK. The rename-safety fix went
|
|
7
|
+
* in family by family, and the families it skipped were the ones that kept the bug: nine of them
|
|
8
|
+
* judged the identity only after `isResolved(news)` and never asked whether the new path was
|
|
9
|
+
* taken. 🔴 MEASURED 2026-09-21 through the engine (rename-families.test.ts): two Bao.AuthRole,
|
|
10
|
+
* Bao.PkiRole, Bao.JwtRole, Bao.KubernetesRole, Bao.SshRole or Bao.Plugin resources that swapped
|
|
11
|
+
* names under `RemovalPolicy.destroy()` both planned `replace`, each wrote over the other, and
|
|
12
|
+
* both old deletes then removed what had just been written: a green deploy, both objects gone.
|
|
13
|
+
* With any other prop still an Output, all nine planned `update` and left the old object live
|
|
14
|
+
* under no state record; for Bao.MfaTotpMethod that wrote a second method and stranded every
|
|
15
|
+
* enrolment.
|
|
16
|
+
*/
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import {
|
|
19
|
+
declaredRolePath,
|
|
20
|
+
declaredString,
|
|
21
|
+
isMoved,
|
|
22
|
+
judgeMove,
|
|
23
|
+
refuseMovedUpdate,
|
|
24
|
+
} from './rename.ts';
|
|
25
|
+
|
|
26
|
+
/** How one family names the object it manages — the thing a rename changes. */
|
|
27
|
+
export interface Identity<A> {
|
|
28
|
+
/** `Bao.JwtRole` etc., for messages. */
|
|
29
|
+
readonly family: string;
|
|
30
|
+
/** The identity a set of props declares; undefined while any part of it is still an Output. */
|
|
31
|
+
readonly declared: (props: unknown) => string | undefined;
|
|
32
|
+
/** The identity a generation's attributes record. */
|
|
33
|
+
readonly recorded: (output: A) => string;
|
|
34
|
+
/** The API path an identity is read at, for the occupied check. Default: the identity. */
|
|
35
|
+
readonly pathOf?: ((identity: string) => string) | undefined;
|
|
36
|
+
/** How the server keys it (`policyKey`). Default: exact. */
|
|
37
|
+
readonly key?: ((identity: string) => string) | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A family keyed by `name` alone, read at `pathOf(name)`. */
|
|
41
|
+
export const nameIdentity = <A extends { readonly name: string }>(
|
|
42
|
+
family: string,
|
|
43
|
+
pathOf: (name: string) => string,
|
|
44
|
+
key?: (name: string) => string,
|
|
45
|
+
): Identity<A> => ({
|
|
46
|
+
declared: (props) => declaredString(props, 'name'),
|
|
47
|
+
family,
|
|
48
|
+
key,
|
|
49
|
+
pathOf,
|
|
50
|
+
recorded: (output) => output.name,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A role at `rolePath(mount, name)`; the identity is that path. `defaultMount` is what an absent
|
|
55
|
+
* `mount` prop means — undefined when `mount` is required.
|
|
56
|
+
*/
|
|
57
|
+
export const roleIdentity = <A extends { readonly mount: string; readonly name: string }>(
|
|
58
|
+
family: string,
|
|
59
|
+
rolePath: (mount: string, name: string) => string,
|
|
60
|
+
defaultMount?: string,
|
|
61
|
+
key?: (path: string) => string,
|
|
62
|
+
): Identity<A> => ({
|
|
63
|
+
declared: (props) => declaredRolePath(props, rolePath, defaultMount),
|
|
64
|
+
family,
|
|
65
|
+
key,
|
|
66
|
+
recorded: (output) => rolePath(output.mount, output.name),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ⚠️ THE LAST SEGMENT LOWERCASED: a role store that folds the name and not the mount. openbao v2.6.2
|
|
71
|
+
* AppRole (builtin/credential/approle/path_role.go:1485, :1537) and Kubernetes
|
|
72
|
+
* (builtin/credential/kubernetes/backend.go:362, path_role.go:396) both store `role/<lowercased
|
|
73
|
+
* name>`, and JWT reads its `name` as a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77),
|
|
74
|
+
* while the router folds no case in a mount path. So `Host` → `host` is the same role, never a
|
|
75
|
+
* replace that would delete, under `destroy`, the role it had just written.
|
|
76
|
+
*/
|
|
77
|
+
export const foldName = (path: string): string =>
|
|
78
|
+
path.replace(/[^/]+$/, (name) => name.toLowerCase());
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `diff`'s first step, before `isResolved(news)`: the move from the identity this resource last
|
|
82
|
+
* wrote or tried to the declared one, or undefined when both name the same object or either is not
|
|
83
|
+
* known yet. ⛔ Fails the plan when the new identity is already taken (`judgeMove`).
|
|
84
|
+
*
|
|
85
|
+
* ★ `olds` STANDS IN FOR `output` WHILE A CREATE OR A REPLACEMENT IS UNFINISHED, which is what
|
|
86
|
+
* catches reverting a move whose new generation failed (rename.ts, `judgeMove`).
|
|
87
|
+
*/
|
|
88
|
+
export const judgeRename = <A>(
|
|
89
|
+
identity: Identity<A>,
|
|
90
|
+
olds: unknown,
|
|
91
|
+
news: unknown,
|
|
92
|
+
output: A | undefined,
|
|
93
|
+
) =>
|
|
94
|
+
judgeMove(
|
|
95
|
+
identity.family,
|
|
96
|
+
output === undefined ? identity.declared(olds) : identity.recorded(output),
|
|
97
|
+
identity.declared(news),
|
|
98
|
+
identity.pathOf ?? ((path) => path),
|
|
99
|
+
identity.key,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `reconcile`'s first step: an `update` whose prior attributes name another object dies before any
|
|
104
|
+
* read or write (`refuseMovedUpdate`). Only reached when the diff could not see the new identity.
|
|
105
|
+
*/
|
|
106
|
+
export const guardRename = <A>(
|
|
107
|
+
identity: Identity<A>,
|
|
108
|
+
news: unknown,
|
|
109
|
+
output: A | undefined,
|
|
110
|
+
): Effect.Effect<void> => {
|
|
111
|
+
if (output === undefined) return Effect.void;
|
|
112
|
+
const from = identity.recorded(output);
|
|
113
|
+
const to = identity.declared(news);
|
|
114
|
+
return to !== undefined && isMoved(from, to, identity.key) === true
|
|
115
|
+
? refuseMovedUpdate(identity.family, from, to)
|
|
116
|
+
: Effect.void;
|
|
117
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A move onto an object that already exists, through Alchemy's own plan and apply (fake-stack.ts).
|
|
3
|
+
* Before `judgeMove` read the target, the first test below was a GREEN deploy that deleted both
|
|
4
|
+
* policies: each new generation wrote over the other's, and each old generation's delete then
|
|
5
|
+
* removed what the other had just written (rename.ts has the measurement).
|
|
6
|
+
*/
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { describe, it } from 'node:test';
|
|
9
|
+
import * as Output from 'alchemy/Output';
|
|
10
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
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 { groupKey } from './cloudflare-group-scope.ts';
|
|
15
|
+
import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
|
|
16
|
+
import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
17
|
+
import { aclPolicies, cloudflareRoles, fakePolicyProviders, proxmoxRoles } from './fake-engines.ts';
|
|
18
|
+
import { type StackBody, withFakeStack, writesOf } from './fake-stack.ts';
|
|
19
|
+
import { BaoPolicy } from './policy.ts';
|
|
20
|
+
import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
|
|
21
|
+
|
|
22
|
+
const OCCUPIED = /would land on an object that already exists/;
|
|
23
|
+
|
|
24
|
+
/** Two policies, `X` and `Y`, each with fragments named after its logical id, under destroy. */
|
|
25
|
+
const pair = (x: string, y: string): StackBody =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
yield* BaoPolicy('X', { fragments: '/p/x', name: x }).pipe(RemovalPolicy.destroy());
|
|
28
|
+
yield* BaoPolicy('Y', { fragments: '/p/y', name: y }).pipe(RemovalPolicy.destroy());
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('Bao.Policy: a rename onto a policy that exists fails the plan', () => {
|
|
32
|
+
it('a swap under destroy writes nothing and deletes nothing', async () => {
|
|
33
|
+
const store = aclPolicies();
|
|
34
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
35
|
+
await stack.deploy(pair('a', 'b'));
|
|
36
|
+
const before = new Map(store.live);
|
|
37
|
+
bao.seen.length = 0;
|
|
38
|
+
await assert.rejects(stack.deploy(pair('b', 'a')), OCCUPIED);
|
|
39
|
+
assert.deepEqual(writesOf(bao.seen), []);
|
|
40
|
+
assert.deepEqual(store.live, before);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('a shift (a → b while b → c) is refused for the name still held', async () => {
|
|
45
|
+
const store = aclPolicies();
|
|
46
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
47
|
+
await stack.deploy(pair('a', 'b'));
|
|
48
|
+
bao.seen.length = 0;
|
|
49
|
+
await assert.rejects(stack.deploy(pair('b', 'c')), /Bao\.Policy: a → b would land/);
|
|
50
|
+
assert.deepEqual(writesOf(bao.seen), []);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reverting a move whose new generation failed is refused while the old name is live', async () => {
|
|
55
|
+
const store = aclPolicies();
|
|
56
|
+
const one = (name: string, dir: string): StackBody =>
|
|
57
|
+
Effect.asVoid(BaoPolicy('X', { fragments: dir, name }).pipe(RemovalPolicy.destroy()));
|
|
58
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
59
|
+
await stack.deploy(one('a', '/p/x'));
|
|
60
|
+
await assert.rejects(stack.deploy(one('b', '/p/empty')), /carry no path grant/);
|
|
61
|
+
bao.seen.length = 0;
|
|
62
|
+
await assert.rejects(stack.deploy(one('a', '/p/x')), /Bao\.Policy: b → a would land/);
|
|
63
|
+
assert.deepEqual(writesOf(bao.seen), []);
|
|
64
|
+
assert.ok(store.live.has('sys/policies/acl/a'));
|
|
65
|
+
// ★ Finishing the move instead replaces cleanly and deletes the old name.
|
|
66
|
+
assert.deepEqual(await stack.deploy(one('b', '/p/x')), { X: 'replace' });
|
|
67
|
+
assert.deepEqual([...store.live.keys()], ['sys/policies/acl/b']);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Every group resolves, to an id named after it. */
|
|
73
|
+
const groups = Layer.succeed(CloudflarePermissionGroups, {
|
|
74
|
+
resolve: (_mount, refs) =>
|
|
75
|
+
Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
|
|
76
|
+
});
|
|
77
|
+
const roleProviders = Layer.mergeAll(
|
|
78
|
+
BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
|
|
79
|
+
BaoProxmoxRoleProvider(),
|
|
80
|
+
FetchHttpClient.layer,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const cf = (id: string, name: string) =>
|
|
84
|
+
BaoCloudflareRole(id, {
|
|
85
|
+
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
86
|
+
maxTtl: '1h',
|
|
87
|
+
mount: 'cloudflare-example',
|
|
88
|
+
name,
|
|
89
|
+
policies: [
|
|
90
|
+
{
|
|
91
|
+
effect: 'allow',
|
|
92
|
+
groupOrder: 'declared',
|
|
93
|
+
groups: ['DNS Read'],
|
|
94
|
+
resource: 'com.cloudflare.api.account.zone.z1',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
ttl: '5m',
|
|
98
|
+
}).pipe(RemovalPolicy.destroy());
|
|
99
|
+
|
|
100
|
+
describe('the role families: a move onto a role that exists fails the plan', () => {
|
|
101
|
+
it('Bao.CloudflareRole: a swap under destroy writes nothing', async () => {
|
|
102
|
+
const store = cloudflareRoles();
|
|
103
|
+
const body = (x: string, y: string): StackBody =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
yield* cf('X', x);
|
|
106
|
+
yield* cf('Y', y);
|
|
107
|
+
});
|
|
108
|
+
await withFakeStack(roleProviders, store, async (stack, bao) => {
|
|
109
|
+
await stack.deploy(body('dns-read', 'dns-edit'));
|
|
110
|
+
bao.seen.length = 0;
|
|
111
|
+
await assert.rejects(stack.deploy(body('dns-edit', 'dns-read')), OCCUPIED);
|
|
112
|
+
assert.deepEqual(writesOf(bao.seen), []);
|
|
113
|
+
assert.equal(store.live.size, 2);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('Bao.ProxmoxRole: a rename onto a role made by hand writes nothing', async () => {
|
|
118
|
+
const store = proxmoxRoles();
|
|
119
|
+
const role = (name: string): StackBody =>
|
|
120
|
+
Effect.asVoid(
|
|
121
|
+
BaoProxmoxRole('Role', {
|
|
122
|
+
maxTtl: '6h',
|
|
123
|
+
mintUser: 'reader@pve',
|
|
124
|
+
mount: 'proxmox-lab',
|
|
125
|
+
name,
|
|
126
|
+
ttl: '1h',
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
await withFakeStack(roleProviders, store, async (stack, bao) => {
|
|
130
|
+
await stack.deploy(role('read'));
|
|
131
|
+
store.live.set('proxmox-lab/roles/audit', { max_ttl: 0, mint_user: 'auditor@pve', ttl: 0 });
|
|
132
|
+
bao.seen.length = 0;
|
|
133
|
+
await assert.rejects(stack.deploy(role('audit')), OCCUPIED);
|
|
134
|
+
assert.deepEqual(writesOf(bao.seen), []);
|
|
135
|
+
assert.equal(store.live.get('proxmox-lab/roles/audit')?.['mint_user'], 'auditor@pve');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Bao.ProxmoxRole: a re-scoping rename whose allowance is still an Output', () => {
|
|
141
|
+
it('defers instead of refusing a value it cannot read yet, then checks it once resolved', async () => {
|
|
142
|
+
const store = proxmoxRoles();
|
|
143
|
+
const base = { maxTtl: '6h', mount: 'proxmox-lab', ttl: '1h' } as const;
|
|
144
|
+
const body = (ttl: string, name: string, mintUser: string): StackBody =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const up = yield* BaoProxmoxRole('Upstream', {
|
|
147
|
+
...base,
|
|
148
|
+
mintUser: 'up@pve',
|
|
149
|
+
name: 'up',
|
|
150
|
+
ttl,
|
|
151
|
+
});
|
|
152
|
+
const allowMintUserChange = Output.interpolate`reader-${up.ttl}@pve`;
|
|
153
|
+
yield* BaoProxmoxRole('Role', { ...base, allowMintUserChange, mintUser, name });
|
|
154
|
+
});
|
|
155
|
+
await withFakeStack(roleProviders, store, async (stack) => {
|
|
156
|
+
await stack.deploy(body('1h', 'read', 'reader@pve'));
|
|
157
|
+
// ★ Before the defer, this died at plan asking for the allowance it had been given.
|
|
158
|
+
await assert.rejects(stack.deploy(body('2h', 'reader', 'writer@pve')), /identity moved/);
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
stack.deploy(body('2h', 'reader', 'writer@pve')),
|
|
161
|
+
/the rename re-scopes mint_user reader@pve/,
|
|
162
|
+
);
|
|
163
|
+
assert.ok(!store.live.has('proxmox-lab/roles/reader'));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|