@homeflare/alchemy 0.6.0 → 0.7.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.
Files changed (120) hide show
  1. package/README.md +54 -10
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/index.js.map +2 -2
  53. package/dist/launchd/secret-tripwire.d.ts +1 -0
  54. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  55. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  56. package/dist/openbao/fake-engines.d.ts +30 -0
  57. package/dist/openbao/fake-engines.d.ts.map +1 -0
  58. package/dist/openbao/fake-stack.d.ts +29 -0
  59. package/dist/openbao/fake-stack.d.ts.map +1 -0
  60. package/dist/openbao/index.js +231 -151
  61. package/dist/openbao/index.js.map +9 -7
  62. package/dist/openbao/policy.d.ts +2 -0
  63. package/dist/openbao/policy.d.ts.map +1 -1
  64. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  65. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  67. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename.d.ts +106 -0
  70. package/dist/openbao/rename.d.ts.map +1 -0
  71. package/docs/caddy.md +180 -0
  72. package/docs/mesh-node.md +188 -0
  73. package/package.json +7 -2
  74. package/src/caddy/admin-calls.ts +128 -0
  75. package/src/caddy/admin-guard.ts +170 -0
  76. package/src/caddy/admin.ts +74 -0
  77. package/src/caddy/caddyfile-tripwire.ts +133 -0
  78. package/src/caddy/config-form.ts +64 -0
  79. package/src/caddy/config-lifecycle.test.ts +218 -0
  80. package/src/caddy/config-lifecycle.ts +148 -0
  81. package/src/caddy/config.ts +116 -0
  82. package/src/caddy/digest.ts +44 -0
  83. package/src/caddy/fake-caddy.ts +160 -0
  84. package/src/caddy/guards.test.ts +156 -0
  85. package/src/caddy/index.ts +25 -0
  86. package/src/caddy/local-admin.test.ts +160 -0
  87. package/src/caddy/local-admin.ts +189 -0
  88. package/src/caddy/providers.test.ts +244 -0
  89. package/src/caddy/providers.ts +17 -0
  90. package/src/caddy/with-file.ts +62 -0
  91. package/src/cloudflare/fake-mesh.ts +181 -0
  92. package/src/cloudflare/index.ts +9 -3
  93. package/src/cloudflare/mesh-node-api.ts +127 -0
  94. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  95. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  96. package/src/cloudflare/mesh-node-form.ts +115 -0
  97. package/src/cloudflare/mesh-node-harness.ts +124 -0
  98. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  99. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  100. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  101. package/src/cloudflare/mesh-node-token.ts +125 -0
  102. package/src/cloudflare/mesh-node.test.ts +172 -0
  103. package/src/cloudflare/mesh-node.ts +120 -0
  104. package/src/cloudflare/providers.ts +20 -2
  105. package/src/index.ts +1 -0
  106. package/src/launchd/secret-tripwire.ts +1 -1
  107. package/src/openbao/REPLACE.md +97 -21
  108. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  109. package/src/openbao/cloudflare-role.ts +30 -8
  110. package/src/openbao/fake-engines.ts +119 -0
  111. package/src/openbao/fake-stack.ts +112 -0
  112. package/src/openbao/policy-rename.test.ts +112 -0
  113. package/src/openbao/policy-wire.test.ts +7 -26
  114. package/src/openbao/policy.ts +27 -4
  115. package/src/openbao/proxmox-role-form.ts +27 -8
  116. package/src/openbao/proxmox-role-wire.ts +24 -0
  117. package/src/openbao/proxmox-role.ts +62 -37
  118. package/src/openbao/rename-occupied.test.ts +166 -0
  119. package/src/openbao/rename.ts +162 -0
  120. package/src/openbao/role-rename.test.ts +205 -0
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters:
3
- * a write parses the duration text into seconds and re-marshals `policies` (path_roles.go:117-146,
4
- * :199-210), a role never written is a 404, and a delete answers 204 whether or not it existed.
2
+ * Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters
3
+ * (fake-engines.ts): a write parses the duration text into seconds and re-marshals `policies`
4
+ * (path_roles.go:117-146, :199-210), a role never written is a 404, and a delete answers 204
5
+ * whether or not it existed.
5
6
  */
6
7
  import assert from 'node:assert/strict';
7
8
  import { describe, it } from 'node:test';
@@ -13,8 +14,8 @@ import {
13
14
  readCloudflareRole,
14
15
  writeCloudflareRole,
15
16
  } from './cloudflare-role-wire.ts';
16
- import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
17
- import { parseDuration } from './mount-form.ts';
17
+ import { run, runFailure, withFake } from './fake-bao.ts';
18
+ import { cloudflareRoles } from './fake-engines.ts';
18
19
 
19
20
  const PROPS: BaoCloudflareRoleProps = {
20
21
  description: 'Read DNS records on one zone. (zone: example.com)',
@@ -34,36 +35,9 @@ const PROPS: BaoCloudflareRoleProps = {
34
35
 
35
36
  const PATH = '/v1/cloudflare-acme-dns/roles/example-com-dns-read';
36
37
 
37
- /** The engine's role store, reduced to what these calls can observe. */
38
- const roleStore = () => {
39
- const roles = new Map<string, Record<string, unknown>>();
40
- return (seen: Seen): Reply => {
41
- if (seen.method === 'PUT') {
42
- const body = JSON.parse(seen.body) as Record<string, string>;
43
- roles.set(seen.path, {
44
- description: body['description'],
45
- max_ttl: parseDuration(body['max_ttl'] ?? ''),
46
- name: seen.path.split('/').at(-1),
47
- // ★ Re-marshalled, as json.Marshal of the stored structs would be.
48
- policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
49
- ttl: parseDuration(body['ttl'] ?? ''),
50
- });
51
- return { status: 204 };
52
- }
53
- if (seen.method === 'DELETE') {
54
- roles.delete(seen.path);
55
- return { status: 204 };
56
- }
57
- const role = roles.get(seen.path);
58
- return role === undefined
59
- ? { json: { errors: [] }, status: 404 }
60
- : { json: { data: role }, status: 200 };
61
- };
62
- };
63
-
64
38
  describe('Cloudflare role wire', () => {
65
39
  it('reads a role that was never written as absent', async () => {
66
- await withFake(roleStore(), async (bao) => {
40
+ await withFake(cloudflareRoles(), async (bao) => {
67
41
  const env = { BAO_ADDR: bao.address };
68
42
  assert.equal(await run(env, readCloudflareRole(PROPS.mount, PROPS.name)), undefined);
69
43
  assert.deepEqual([bao.seen[0]?.method, bao.seen[0]?.path], ['GET', PATH]);
@@ -71,7 +45,7 @@ describe('Cloudflare role wire', () => {
71
45
  });
72
46
 
73
47
  it('writes the four fields and reads them back with no difference', async () => {
74
- await withFake(roleStore(), async (bao) => {
48
+ await withFake(cloudflareRoles(), async (bao) => {
75
49
  const env = { BAO_ADDR: bao.address };
76
50
  const { policies } = resolvePolicies(
77
51
  PROPS.policies,
@@ -38,6 +38,13 @@ import {
38
38
  readCloudflareRole,
39
39
  writeCloudflareRole,
40
40
  } from './cloudflare-role-wire.ts';
41
+ import {
42
+ declaredRolePath,
43
+ isMoved,
44
+ judgeMove,
45
+ refuseMovedUpdate,
46
+ triedRolePath,
47
+ } from './rename.ts';
41
48
 
42
49
  export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
43
50
 
@@ -112,14 +119,24 @@ export const BaoCloudflareRoleProvider = () =>
112
119
  * OpenBao UI changes what every future token can do while the digest in Postgres still
113
120
  * says everything is fine.
114
121
  *
115
- * ⚠️ NO `replace`, EVEN FOR A NAME OR MOUNT CHANGE. A different path is a different object,
116
- * so diff reads the new path, finds it absent and plans `update`; reconcile creates it and
117
- * the old role is left live, retained and unmanaged — the same outcome apply-roles.py's
118
- * UNTRACKED report had. declareCloudflareRoles puts mount and name in the resource id, so
119
- * a rename there is a new resource anyway.
122
+ * ⛔ A NEW `mount` OR `name` IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
123
+ * 2026-09-21 this read the new path, found it absent and planned `update`: reconcile wrote
124
+ * the new role and the old one stayed live and mintable under no state record. ⛔ A move
125
+ * onto a role that already exists fails the plan (`judgeMove`).
126
+ * ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL MINTS for any token whose policy reaches its
127
+ * `creds/<name>`. Under `destroy` every consumer still minting from the old path fails at
128
+ * the delete, so move them in the same PR (REPLACE.md).
129
+ * ★ A STACK THAT PUTS mount AND name IN THE LOGICAL ID never reaches this branch
130
+ * (homeflare-openbao's declareCloudflareRoles does). There a rename is a new logical id,
131
+ * and the old id leaves the stack as an orphan delete, which `retain` keeps live.
120
132
  */
121
- diff: Effect.fn(function* ({ news, output }) {
122
- if (output === undefined || !isResolved(news)) return undefined;
133
+ diff: Effect.fn(function* ({ news, olds, output }) {
134
+ const tried = triedRolePath(output, olds, rolePath);
135
+ const declared = declaredRolePath(news, rolePath);
136
+ const move = yield* judgeMove('Bao.CloudflareRole', tried, declared, (path) => path);
137
+ if (output === undefined) return undefined;
138
+ if (move !== undefined) return { action: 'replace' } as const;
139
+ if (!isResolved(news)) return undefined;
123
140
  /**
124
141
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop — route it to reconcile,
125
142
  * which says why (pki-role.ts has the same guard).
@@ -133,8 +150,13 @@ export const BaoCloudflareRoleProvider = () =>
133
150
  : ({ action: 'update' } as const);
134
151
  }),
135
152
 
136
- reconcile: Effect.fn(function* ({ news }) {
153
+ reconcile: Effect.fn(function* ({ news, output }) {
137
154
  const path = rolePath(news.mount, news.name);
155
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
156
+ const before = output === undefined ? path : rolePath(output.mount, output.name);
157
+ if (isMoved(before, path) === true) {
158
+ return yield* refuseMovedUpdate('Bao.CloudflareRole', before, path);
159
+ }
138
160
  const refusal = refusalOf(news);
139
161
  if (refusal !== undefined) {
140
162
  return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
3
+ * plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
4
+ * real server does. That is the point: a rename test is only as good as the fake's idea of "the
5
+ * same object".
6
+ *
7
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
+ */
9
+ import * as Effect from 'effect/Effect';
10
+ import * as FileSystem from 'effect/FileSystem';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as Path from 'effect/Path';
13
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
14
+ import type { Reply, Seen } from './fake-bao.ts';
15
+ import { parseDuration } from './mount-form.ts';
16
+ import { trimTrailing } from './mount-path.ts';
17
+ import { BaoPolicyProvider } from './policy.ts';
18
+ import { policyKey } from './rename.ts';
19
+
20
+ type Stored = Record<string, unknown>;
21
+
22
+ /** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
23
+ export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
24
+
25
+ const ABSENT: Reply = { json: { errors: [] }, status: 404 };
26
+
27
+ const store = (
28
+ write: (path: string, body: Record<string, string>, before: Stored | undefined) => Stored,
29
+ key: (path: string) => string = (path) => path,
30
+ ): Store => {
31
+ const live = new Map<string, Stored>();
32
+ const answer = (seen: Seen): Reply => {
33
+ const path = key(seen.path.replace(/^\/v1\//, ''));
34
+ if (seen.method === 'PUT') {
35
+ const body = JSON.parse(seen.body) as Record<string, string>;
36
+ live.set(path, write(path, body, live.get(path)));
37
+ return { status: 204 };
38
+ }
39
+ // ★ A delete answers 204 whether or not the object existed, as all three do.
40
+ if (seen.method === 'DELETE') {
41
+ live.delete(path);
42
+ return { status: 204 };
43
+ }
44
+ const data = live.get(path);
45
+ return data === undefined ? ABSENT : { json: { data }, status: 200 };
46
+ };
47
+ return Object.assign(answer, { live });
48
+ };
49
+
50
+ const lastSegment = (path: string) => path.split('/').at(-1) ?? '';
51
+
52
+ /**
53
+ * `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
54
+ * server — digest.ts), and keys the name as OpenBao does: trimmed and lowercased (`policyKey`).
55
+ */
56
+ export const aclPolicies = (): Store =>
57
+ store(
58
+ (path, body) => ({
59
+ name: lastSegment(path),
60
+ policy: trimTrailing(body['policy'] ?? '', '\n'),
61
+ }),
62
+ (path) =>
63
+ path.startsWith('sys/policies/acl/')
64
+ ? `sys/policies/acl/${policyKey(decodeURIComponent(lastSegment(path)))}`
65
+ : path,
66
+ );
67
+
68
+ /**
69
+ * `<mount>/roles/<name>` on the Cloudflare engine. A write parses the duration text into seconds and
70
+ * re-marshals `policies` (path_roles.go:117-146, :199-210). Names are stored verbatim.
71
+ */
72
+ export const cloudflareRoles = (): Store =>
73
+ store((path, body) => ({
74
+ description: body['description'],
75
+ max_ttl: parseDuration(body['max_ttl'] ?? ''),
76
+ name: lastSegment(path),
77
+ // ★ Re-marshalled, as json.Marshal of the stored structs would be.
78
+ policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
79
+ ttl: parseDuration(body['ttl'] ?? ''),
80
+ }));
81
+
82
+ /**
83
+ * `<mount>/roles/<name>` on the proxmox engine. A write MERGES into the stored role, and TTLs go over
84
+ * as seconds (homeflare-openbao-plugins secrets/proxmox/path_roles.go, pathRolesWrite).
85
+ */
86
+ export const proxmoxRoles = (): Store =>
87
+ store((path, body, before) => ({
88
+ max_ttl: 0,
89
+ name: lastSegment(path),
90
+ ttl: 0,
91
+ ...before,
92
+ ...(body['mint_user'] === undefined ? {} : { mint_user: body['mint_user'] }),
93
+ ...(body['ttl'] === undefined ? {} : { ttl: Number(body['ttl']) }),
94
+ ...(body['max_ttl'] === undefined ? {} : { max_ttl: Number(body['max_ttl']) }),
95
+ }));
96
+
97
+ /**
98
+ * Bao.Policy's provider over fragment directories that exist only here: `…/empty` holds no file,
99
+ * `…/two` holds two, and any other directory one. Each file is one grant named after its own path,
100
+ * so a directory's content is its name.
101
+ */
102
+ export const fakePolicyProviders = Layer.mergeAll(
103
+ BaoPolicyProvider().pipe(
104
+ Layer.provide(
105
+ Layer.mergeAll(
106
+ FileSystem.layerNoop({
107
+ readDirectory: (dir) =>
108
+ Effect.succeed(
109
+ dir.endsWith('/empty') ? [] : dir.endsWith('/two') ? ['a.hcl', 'b.hcl'] : ['a.hcl'],
110
+ ),
111
+ readFileString: (path) =>
112
+ Effect.succeed(`path "${path}" {\n capabilities = ["read"]\n}`),
113
+ }),
114
+ Path.layer,
115
+ ),
116
+ ),
117
+ ),
118
+ FetchHttpClient.layer,
119
+ );
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Alchemy's own Plan and Apply, run in-process over an in-memory state store, so a test can deploy a
3
+ * declaration, change it, deploy again, and see what the ENGINE did with a provider's answers: which
4
+ * action it planned, and which calls reached the fake.
5
+ *
6
+ * ★ WHY THE ENGINE AND NOT THE HANDLERS ALONE. A diff that answers `replace` proves nothing by itself.
7
+ * What matters is that Apply then creates the new generation, deletes the OLD one with the OLD
8
+ * attributes, and keeps it under `retain`. Those are claims about alchemy@2.0.0-beta.79, and this
9
+ * harness measures them instead of restating them (REPLACE.md).
10
+ * ★ NOT alchemy/Test/Bun. Its runtime builds the CLI's profile and credentials stores, which read
11
+ * files under the home directory, plus a file logger. This needs none of that: the providers, an
12
+ * in-memory state store, a stage, and the stack's own services. It is the composition
13
+ * alchemy/src/Deploy.ts uses, minus the platform layer.
14
+ *
15
+ * ⛔ TEST-ONLY, AND NEVER A REAL VAULT. `BaoEnv` is provided explicitly, so a BAO_ADDR or BAO_TOKEN
16
+ * in the shell running the tests is never read. No provider imports this file.
17
+ */
18
+ import * as Alchemy from 'alchemy';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Layer from 'effect/Layer';
21
+ import type { BaoEnvironment } from './bao-address.ts';
22
+ import { BaoEnv } from './bao-http.ts';
23
+ import { type Fake, type Reply, type Seen, fakeBao } from './fake-bao.ts';
24
+
25
+ /** The planned action for each resource, deletions included, keyed by FQN. */
26
+ export type Planned = Readonly<Record<string, string>>;
27
+
28
+ /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
29
+ export type StackBody = Effect.Effect<unknown, unknown, unknown>;
30
+
31
+ export interface FakeStack {
32
+ /** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
33
+ readonly deploy: (body: StackBody) => Promise<Planned>;
34
+ }
35
+
36
+ type Node = { readonly action: string };
37
+ type PlanView = {
38
+ readonly resources: Readonly<Record<string, Node>>;
39
+ readonly deletions: Readonly<Record<string, Node | undefined>>;
40
+ };
41
+
42
+ const actionsOf = (plan: PlanView): Planned => {
43
+ const out: Record<string, string> = {};
44
+ for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
45
+ for (const [fqn, node] of Object.entries(plan.deletions)) {
46
+ if (node !== undefined) out[fqn] = node.action;
47
+ }
48
+ return out;
49
+ };
50
+
51
+ /**
52
+ * One stack over one state store, shared by every `deploy`, so the second deploy plans against what
53
+ * the first one recorded.
54
+ *
55
+ * ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY, NOT IN THE PROVIDERS. `Alchemy.Stack` types its body
56
+ * against every service a CLI run provides. Here the body needs only the providers given, and a
57
+ * missing one fails the test by name ("Provider not found for …").
58
+ */
59
+ export const fakeStack = <ROut, E, RIn>(
60
+ providers: Layer.Layer<ROut, E, RIn>,
61
+ env: BaoEnvironment,
62
+ name = 'RenameStack',
63
+ ): FakeStack => {
64
+ const state = Alchemy.inMemoryState();
65
+ const stack = Alchemy.Stack as unknown as (
66
+ stackName: string,
67
+ options: { providers: typeof providers; state: typeof state },
68
+ body: StackBody,
69
+ ) => Effect.Effect<{ readonly services: never }, unknown, never>;
70
+ const plan = Alchemy.Plan.make as unknown as (
71
+ compiled: unknown,
72
+ ) => Effect.Effect<PlanView, unknown, never>;
73
+ const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
74
+
75
+ const deploy = (body: StackBody) =>
76
+ Effect.gen(function* () {
77
+ const compiled = yield* stack(name, { providers, state }, body);
78
+ return yield* Effect.gen(function* () {
79
+ const planned = yield* plan(compiled);
80
+ yield* apply(planned);
81
+ return actionsOf(planned);
82
+ }).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
83
+ }).pipe(
84
+ Effect.provideService(Alchemy.Stage, 'test'),
85
+ Effect.provideService(BaoEnv, env),
86
+ Effect.provide(state),
87
+ Effect.scoped,
88
+ );
89
+
90
+ return { deploy: (body) => Effect.runPromise(deploy(body) as Effect.Effect<Planned>) };
91
+ };
92
+
93
+ /**
94
+ * A fake engine answering with `answer`, and one stack over it, for one test. The fake stops however
95
+ * `body` ends.
96
+ */
97
+ export const withFakeStack = async <ROut, E, RIn>(
98
+ providers: Layer.Layer<ROut, E, RIn>,
99
+ answer: (seen: Seen) => Reply,
100
+ body: (stack: FakeStack, bao: Fake) => Promise<void>,
101
+ ): Promise<void> => {
102
+ const bao = fakeBao(answer);
103
+ try {
104
+ await body(fakeStack(providers, { BAO_ADDR: bao.address }), bao);
105
+ } finally {
106
+ bao.stop();
107
+ }
108
+ };
109
+
110
+ /** Every call that changed something, as `METHOD /v1/path`, in order. */
111
+ export const writesOf = (seen: readonly Seen[]): string[] =>
112
+ seen.filter((each) => each.method !== 'GET').map((each) => `${each.method} ${each.path}`);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Renaming a Bao.Policy, through Alchemy's own plan and apply (fake-stack.ts) against a fake policy
3
+ * store keyed as OpenBao keys it (fake-engines.ts). Before 2026-09-21 the second deploy of every
4
+ * rename below planned `update` and left the old policy live, under `destroy` too.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import * as Output from 'alchemy/Output';
9
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
10
+ import * as Effect from 'effect/Effect';
11
+ import type { Fake } from './fake-bao.ts';
12
+ import { aclPolicies, fakePolicyProviders } from './fake-engines.ts';
13
+ import { type FakeStack, type StackBody, withFakeStack, writesOf as writes } from './fake-stack.ts';
14
+ import { BaoPolicy } from './policy.ts';
15
+
16
+ /** A fake policy store and a stack over it; `live` lists the policy names the store holds. */
17
+ const withStack = (body: (stack: FakeStack, bao: Fake, live: () => string[]) => Promise<void>) => {
18
+ const store = aclPolicies();
19
+ return withFakeStack(fakePolicyProviders, store, (stack, bao) =>
20
+ body(stack, bao, () =>
21
+ [...store.live.keys()].map((path) => path.replace('sys/policies/acl/', '')).sort(),
22
+ ),
23
+ );
24
+ };
25
+
26
+ type Removal = typeof RemovalPolicy.destroy;
27
+ const policy = (name: string, removal: Removal = RemovalPolicy.retain): StackBody =>
28
+ Effect.asVoid(BaoPolicy('Policy', { fragments: '/policies/app', name }).pipe(removal()));
29
+
30
+ describe('Bao.Policy rename, through the engine', () => {
31
+ it('plans replace, writes the new name first, then deletes the OLD name under destroy', async () => {
32
+ await withStack(async (stack, bao, live) => {
33
+ assert.deepEqual(await stack.deploy(policy('app-old', RemovalPolicy.destroy)), {
34
+ Policy: 'create',
35
+ });
36
+ bao.seen.length = 0;
37
+ assert.deepEqual(await stack.deploy(policy('app-new', RemovalPolicy.destroy)), {
38
+ Policy: 'replace',
39
+ });
40
+ assert.deepEqual(writes(bao.seen), [
41
+ 'PUT /v1/sys/policies/acl/app-new',
42
+ 'DELETE /v1/sys/policies/acl/app-old',
43
+ ]);
44
+ assert.deepEqual(live(), ['app-new']);
45
+ });
46
+ });
47
+
48
+ it('still plans replace under the default retain, and keeps the old policy live', async () => {
49
+ await withStack(async (stack, bao, live) => {
50
+ await stack.deploy(policy('app-old'));
51
+ assert.deepEqual(await stack.deploy(policy('app-new')), { Policy: 'replace' });
52
+ assert.ok(!writes(bao.seen).some((each) => each.startsWith('DELETE')));
53
+ assert.deepEqual(live(), ['app-new', 'app-old']);
54
+ });
55
+ });
56
+
57
+ it('treats a change of case as the same policy: noop, and nothing deleted', async () => {
58
+ await withStack(async (stack, bao, live) => {
59
+ await stack.deploy(policy('App', RemovalPolicy.destroy));
60
+ bao.seen.length = 0;
61
+ assert.deepEqual(await stack.deploy(policy('app', RemovalPolicy.destroy)), {
62
+ Policy: 'noop',
63
+ });
64
+ assert.deepEqual(writes(bao.seen), []);
65
+ assert.deepEqual(live(), ['app']);
66
+ });
67
+ });
68
+
69
+ it('plans replace while `fragments` is still an Output of a changing upstream', async () => {
70
+ const body = (dir: string, name: string): StackBody =>
71
+ Effect.gen(function* () {
72
+ const upstream = yield* BaoPolicy('Upstream', { fragments: dir, name: 'upstream' });
73
+ yield* BaoPolicy('Policy', {
74
+ fragments: Output.interpolate`/policies/${upstream.grants}`,
75
+ name,
76
+ }).pipe(RemovalPolicy.destroy());
77
+ });
78
+ await withStack(async (stack, _bao, live) => {
79
+ await stack.deploy(body('/policies/one', 'app-old'));
80
+ const planned = await stack.deploy(body('/policies/two', 'app-new'));
81
+ assert.deepEqual(planned, { Policy: 'replace', Upstream: 'update' });
82
+ assert.deepEqual(live(), ['app-new', 'upstream']);
83
+ });
84
+ });
85
+
86
+ it('refuses an update across a rename it could not see, then replaces on the next deploy', async () => {
87
+ // ★ The name is an Output of an upstream that is updating, so the diff cannot see it at plan.
88
+ const body = (dir: string): StackBody =>
89
+ Effect.gen(function* () {
90
+ const upstream = yield* BaoPolicy('Upstream', { fragments: dir, name: 'upstream' });
91
+ yield* BaoPolicy('Policy', {
92
+ fragments: '/policies/app',
93
+ name: Output.interpolate`app-${upstream.grants}`,
94
+ }).pipe(RemovalPolicy.destroy());
95
+ });
96
+ await withStack(async (stack, bao, live) => {
97
+ await stack.deploy(body('/policies/one'));
98
+ assert.deepEqual(live(), ['app-1', 'upstream']);
99
+ bao.seen.length = 0;
100
+ await assert.rejects(
101
+ stack.deploy(body('/policies/two')),
102
+ /identity moved from app-1 to app-2/,
103
+ );
104
+ assert.deepEqual(writes(bao.seen), ['PUT /v1/sys/policies/acl/upstream']);
105
+ assert.deepEqual(await stack.deploy(body('/policies/two')), {
106
+ Policy: 'replace',
107
+ Upstream: 'noop',
108
+ });
109
+ assert.deepEqual(live(), ['app-2', 'upstream']);
110
+ });
111
+ });
112
+ });
@@ -1,43 +1,24 @@
1
1
  /**
2
- * Bao.Policy's calls against a fake that behaves like OpenBao where it matters: it STRIPS the
3
- * policy's trailing newline on write (measured on the live server — see digest.ts), answers 404 for
4
- * a policy it does not have, and 204 for a delete whether or not the policy existed.
2
+ * Bao.Policy's calls against a fake that behaves like OpenBao where it matters (fake-engines.ts): it
3
+ * STRIPS the policy's trailing newline on write (measured on the live server — see digest.ts),
4
+ * answers 404 for a policy it does not have, and 204 for a delete whether or not it existed.
5
5
  */
6
6
  import assert from 'node:assert/strict';
7
7
  import { describe, it } from 'node:test';
8
8
  import { BaoError } from './bao-status.ts';
9
9
  import { sha256 } from './digest.ts';
10
- import { type Seen, run, runFailure, withFake } from './fake-bao.ts';
10
+ import { run, runFailure, withFake } from './fake-bao.ts';
11
+ import { aclPolicies } from './fake-engines.ts';
11
12
  import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
12
13
 
13
14
  const PREFIX = '/v1/sys/policies/acl/';
14
15
 
15
- const policyStore = () => {
16
- const policies = new Map<string, string>();
17
- return (seen: Seen) => {
18
- const name = seen.path.slice(PREFIX.length);
19
- if (seen.method === 'PUT') {
20
- const { policy } = JSON.parse(seen.body) as { policy: string };
21
- policies.set(name, policy.replace(/\n+$/, ''));
22
- return { status: 204 };
23
- }
24
- if (seen.method === 'DELETE') {
25
- policies.delete(name);
26
- return { status: 204 };
27
- }
28
- const policy = policies.get(name);
29
- return policy === undefined
30
- ? { json: { errors: [] }, status: 404 }
31
- : { json: { data: { name, policy } }, status: 200 };
32
- };
33
- };
34
-
35
16
  /** Two fragments, each followed by a newline — exactly how policy.ts assembles them. */
36
17
  const ASSEMBLED = 'path "kv/data/a" {\n capabilities = ["read"]\n}\n' + 'path "kv/data/b" {}\n';
37
18
 
38
19
  describe('policy wire', () => {
39
20
  it('reads a policy that was never written as absent', async () => {
40
- await withFake(policyStore(), async (bao) => {
21
+ await withFake(aclPolicies(), async (bao) => {
41
22
  assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), '');
42
23
  });
43
24
  });
@@ -45,7 +26,7 @@ describe('policy wire', () => {
45
26
  // ⛔ THE FOREVER-DIFF. Live text comes back one newline short; the digests must still agree, or
46
27
  // every plan says `update` and reconcile rewrites a policy that already matches.
47
28
  it('round-trips through the stripped newline with equal digests', async () => {
48
- await withFake(policyStore(), async (bao) => {
29
+ await withFake(aclPolicies(), async (bao) => {
49
30
  const env = { BAO_ADDR: bao.address };
50
31
  await run(env, writePolicy('homeflare-llm', ASSEMBLED));
51
32
  assert.equal(bao.seen[0]?.method, 'PUT');
@@ -7,7 +7,8 @@ import * as Path from 'effect/Path';
7
7
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
8
8
  import { sha256 } from './digest.ts';
9
9
  import { isEmptyAssembly } from './policy-assembly.ts';
10
- import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
10
+ import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
11
+ import { declaredString, isMoved, judgeMove, policyKey, refuseMovedUpdate } from './rename.ts';
11
12
 
12
13
  /**
13
14
  * An OpenBao ACL policy, assembled from the HCL fragments that declare it.
@@ -91,6 +92,8 @@ export interface BaoPolicy extends Resource<
91
92
  * orphaned resource; a caller that means it opts in with `.pipe(RemovalPolicy.destroy())`. This
92
93
  * is Terraform's `prevent_destroy`, not a stubbed operation — a delete that silently did nothing
93
94
  * would lie to whoever read the plan.
95
+ * ⚠️ `retain` ALSO KEEPS THE OLD GENERATION OF A RENAME. A new `name` plans `replace`, and under
96
+ * `retain` the old policy is left live and unmanaged (rename.ts, REPLACE.md).
94
97
  */
95
98
  export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
96
99
  defaultRemovalPolicy: 'retain',
@@ -141,11 +144,27 @@ export const BaoPolicyProvider = () =>
141
144
  * OpenBao UI is exactly the drift the check-* gates exist to catch, and a
142
145
  * provider that trusted its own state would report `noop` straight through it.
143
146
  */
144
- diff: Effect.fn(function* ({ news, output }) {
147
+ diff: Effect.fn(function* ({ news, olds, output }) {
148
+ /**
149
+ * ⛔ A RENAMED POLICY IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
150
+ * 2026-09-21 this read the new name, found nothing and planned `update`: the new policy
151
+ * was written and the old one kept every grant under no state record. Names compare as
152
+ * OpenBao keys them (`policyKey`), so a change of case is the same policy, never a
153
+ * replace. ⛔ A rename onto a policy that already exists fails the plan (`judgeMove`).
154
+ * ⚠️ UNDER THE DEFAULT `retain` THE OLD POLICY STAYS LIVE, GRANTS AND ALL, for every token,
155
+ * role and group that still names it. Under `destroy` they lose those grants at the
156
+ * delete, so a role outside this graph that names the old policy must move in the same
157
+ * PR (REPLACE.md).
158
+ */
159
+ const tried = output?.name ?? declaredString(olds, 'name');
160
+ const declared = declaredString(news, 'name');
161
+ const move = yield* judgeMove('Bao.Policy', tried, declared, policyPath, policyKey);
162
+ if (output === undefined) return undefined;
163
+ if (move !== undefined) return { action: 'replace' } as const;
145
164
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
146
165
  // own providers guard with isResolved and skip rather than guess; a diff that
147
166
  // read a Config as a string would compare a placeholder to real HCL.
148
- if (output === undefined || !isResolved(news)) return undefined;
167
+ if (!isResolved(news)) return undefined;
149
168
  const { joined } = yield* assemble(news.fragments);
150
169
  const live = yield* readPolicy(news.name);
151
170
  return live.length > 0 && sha256(live) === sha256(joined)
@@ -153,8 +172,12 @@ export const BaoPolicyProvider = () =>
153
172
  : ({ action: 'update' } as const);
154
173
  }),
155
174
 
156
- reconcile: Effect.fn(function* ({ news }) {
175
+ reconcile: Effect.fn(function* ({ news, output }) {
157
176
  const name = news.name;
177
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
178
+ if (output !== undefined && isMoved(output.name, name, policyKey) === true) {
179
+ return yield* refuseMovedUpdate(`Bao.Policy ${name}`, output.name, name);
180
+ }
158
181
  const { joined, parts } = yield* assemble(news.fragments);
159
182
  /**
160
183
  * ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
@@ -109,7 +109,7 @@ const ttlText = (seconds: number | undefined, fallback = '0') => {
109
109
  export const rolePath = (mount: string, name: string) => `${mountPath(mount)}/roles/${name}`;
110
110
 
111
111
  export const attributesOf = (
112
- props: BaoProxmoxRoleProps,
112
+ props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>,
113
113
  live: Record<string, unknown>,
114
114
  ): BaoProxmoxRoleAttributes => {
115
115
  const attrs = {
@@ -129,13 +129,13 @@ export const attributesOf = (
129
129
  * The body for `PUT <mount>/roles/<name>` — all strings, the `k=v` pairs `bao write` sent — or
130
130
  * the prop names whose duration would not parse.
131
131
  *
132
- * ⛔ A WRITE TO roles/{name} REPLACES THE ROLE, IT DOES NOT MERGE. An omitted `ttl` is written
133
- * as the field's zero value, so a resource that treated undeclared TTLs as "unmanaged" — the
134
- * Bao.Mount shape — would silently reset a lease ceiling every time it rewrote the role for an
135
- * unrelated reason. Both TTLs are therefore required props and EVERY write carries all three
136
- * fields. REASONED from the Vault secrets-framework write semantics plus this plugin's schema
137
- * marking only `mint_user` required; NOT measured, because this session was read-only and
138
- * proving it would have meant writing to the live engine.
132
+ * ⛔ EVERY WRITE CARRIES ALL THREE FIELDS, and both TTLs are required props, so `matches` covers
133
+ * the whole role and no field is left unmanaged behind a green plan.
134
+ * ⚠️ CORRECTED 2026-09-21 FROM THE PLUGIN SOURCE. This said a write REPLACES the role and resets an
135
+ * omitted TTL to zero. The port in homeflare-openbao-plugins (secrets/proxmox/path_roles.go,
136
+ * pathRolesWrite, commit 0e79154) MERGES: it loads the stored role and sets only the fields
137
+ * present (`d.GetOk`). An omitted TTL would keep its old value, which is still a value this
138
+ * resource would not be managing. The build running live was not re-checked against that source.
139
139
  *
140
140
  * ⚠️ Seconds go on the wire as integers, not as `1h`. The schema declares both TTLs
141
141
  * `"format": "seconds"`, and an integer is accepted unambiguously by every path that reads
@@ -167,6 +167,25 @@ const sameTtl = (want: string, have: string) => {
167
167
  return wantSeconds !== undefined && wantSeconds === haveSeconds;
168
168
  };
169
169
 
170
+ /**
171
+ * Why moving the role's mint user from `before` to the declared one must be refused, or undefined.
172
+ * The ⛔ on reconcile in proxmox-role.ts says why a re-scope needs `allowMintUserChange`.
173
+ *
174
+ * ★ ONE DEFINITION FOR BOTH PLACES THAT ASK. Reconcile asks it of the live role at the same path.
175
+ * The diff asks it across a rename, where `before` is the role being replaced: a rename writes a
176
+ * brand-new role, so reconcile alone would see nothing to re-scope, and a rename would become the
177
+ * way around the guard.
178
+ */
179
+ export const rescopeRefusal = (
180
+ before: string,
181
+ mintUser: string,
182
+ allowMintUserChange: string | undefined,
183
+ ): string | undefined =>
184
+ before === mintUser || allowMintUserChange === before
185
+ ? undefined
186
+ : `re-scopes mint_user ${before} → ${mintUser}. Every token this role mints inherits ` +
187
+ `the ACL of that PVE user. To re-scope deliberately, declare allowMintUserChange: '${before}'.`;
188
+
170
189
  /** True when the live role already matches the declaration on every field the plugin stores. */
171
190
  export const matches = (attributes: BaoProxmoxRoleAttributes, props: BaoProxmoxRoleProps) =>
172
191
  attributes.mintUser === props.mintUser &&