@homeflare/alchemy 0.7.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.
Files changed (137) hide show
  1. package/README.md +26 -37
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +71 -13
  11. package/dist/caddy/index.js.map +9 -9
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-sudo.d.ts +50 -0
  15. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  16. package/dist/launchd/host-effect.d.ts +18 -9
  17. package/dist/launchd/host-effect.d.ts.map +1 -1
  18. package/dist/launchd/host-file-form.d.ts +5 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  21. package/dist/launchd/index.d.ts +2 -0
  22. package/dist/launchd/index.d.ts.map +1 -1
  23. package/dist/launchd/index.js +342 -8
  24. package/dist/launchd/index.js.map +15 -11
  25. package/dist/launchd/job-preflight.d.ts.map +1 -1
  26. package/dist/launchd/job-validate.d.ts +2 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -1
  28. package/dist/launchd/local-runner.d.ts +2 -0
  29. package/dist/launchd/local-runner.d.ts.map +1 -1
  30. package/dist/launchd/providers.d.ts +2 -1
  31. package/dist/launchd/providers.d.ts.map +1 -1
  32. package/dist/launchd/runner.d.ts +7 -5
  33. package/dist/launchd/runner.d.ts.map +1 -1
  34. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  35. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  36. package/dist/launchd/sudo-guard.d.ts +56 -0
  37. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  38. package/dist/launchd/sudo-runner.d.ts +60 -0
  39. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  40. package/dist/launchd/sudo-stage.d.ts +7 -0
  41. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  42. package/dist/openbao/auth-role-form.d.ts +0 -9
  43. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-role.d.ts +3 -2
  45. package/dist/openbao/auth-role.d.ts.map +1 -1
  46. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  47. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  48. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  49. package/dist/openbao/fake-engines.d.ts +8 -2
  50. package/dist/openbao/fake-engines.d.ts.map +1 -1
  51. package/dist/openbao/fake-families.d.ts +49 -0
  52. package/dist/openbao/fake-families.d.ts.map +1 -0
  53. package/dist/openbao/host-approles.d.ts.map +1 -1
  54. package/dist/openbao/index.js +359 -280
  55. package/dist/openbao/index.js.map +21 -19
  56. package/dist/openbao/jwt-config.d.ts +5 -0
  57. package/dist/openbao/jwt-config.d.ts.map +1 -1
  58. package/dist/openbao/jwt-role.d.ts +2 -1
  59. package/dist/openbao/jwt-role.d.ts.map +1 -1
  60. package/dist/openbao/kubernetes-role.d.ts +2 -1
  61. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  62. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  63. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  64. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  65. package/dist/openbao/pki-role.d.ts.map +1 -1
  66. package/dist/openbao/plugin.d.ts.map +1 -1
  67. package/dist/openbao/policy.d.ts.map +1 -1
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename-identity.d.ts +69 -0
  70. package/dist/openbao/rename-identity.d.ts.map +1 -0
  71. package/dist/openbao/rename.d.ts +13 -12
  72. package/dist/openbao/rename.d.ts.map +1 -1
  73. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  74. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  75. package/dist/openbao/ssh-role.d.ts.map +1 -1
  76. package/docs/caddy-admin.md +24 -0
  77. package/docs/caddy.md +73 -54
  78. package/docs/launchd-sudo.md +122 -0
  79. package/docs/launchd.md +7 -4
  80. package/docs/peers.md +39 -0
  81. package/package.json +1 -1
  82. package/src/caddy/adopt-scope.test.ts +82 -0
  83. package/src/caddy/config-adopt.test.ts +235 -0
  84. package/src/caddy/config-lifecycle.test.ts +31 -53
  85. package/src/caddy/config-lifecycle.ts +98 -7
  86. package/src/caddy/config.ts +48 -11
  87. package/src/caddy/fake-caddy.ts +19 -0
  88. package/src/caddy/index.ts +2 -1
  89. package/src/caddy/providers.test.ts +8 -23
  90. package/src/caddy/with-file.ts +7 -3
  91. package/src/launchd/fake-sudo.ts +136 -0
  92. package/src/launchd/host-effect.ts +33 -1
  93. package/src/launchd/host-file-form.ts +10 -2
  94. package/src/launchd/host-file-lifecycle.ts +2 -1
  95. package/src/launchd/index.ts +2 -0
  96. package/src/launchd/job-preflight.ts +3 -2
  97. package/src/launchd/job-validate.ts +1 -1
  98. package/src/launchd/local-runner.ts +6 -3
  99. package/src/launchd/providers.test.ts +3 -0
  100. package/src/launchd/providers.ts +2 -1
  101. package/src/launchd/runner.ts +7 -5
  102. package/src/launchd/sudo-allowlist.test.ts +191 -0
  103. package/src/launchd/sudo-allowlist.ts +213 -0
  104. package/src/launchd/sudo-guard.test.ts +100 -0
  105. package/src/launchd/sudo-guard.ts +151 -0
  106. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  107. package/src/launchd/sudo-runner-files.test.ts +240 -0
  108. package/src/launchd/sudo-runner.test.ts +126 -0
  109. package/src/launchd/sudo-runner.ts +235 -0
  110. package/src/launchd/sudo-stage.ts +28 -0
  111. package/src/openbao/README.md +8 -2
  112. package/src/openbao/REPLACE.md +65 -37
  113. package/src/openbao/auth-role-form.test.ts +2 -16
  114. package/src/openbao/auth-role-form.ts +0 -11
  115. package/src/openbao/auth-role.ts +35 -14
  116. package/src/openbao/cloudflare-role.ts +6 -14
  117. package/src/openbao/fake-engines-roles.ts +114 -0
  118. package/src/openbao/fake-engines.ts +18 -11
  119. package/src/openbao/fake-families.ts +182 -0
  120. package/src/openbao/host-approles.test.ts +15 -2
  121. package/src/openbao/host-approles.ts +19 -4
  122. package/src/openbao/jwt-config.ts +26 -5
  123. package/src/openbao/jwt-role.ts +24 -8
  124. package/src/openbao/kubernetes-role.ts +20 -8
  125. package/src/openbao/mfa-enforcement.ts +23 -8
  126. package/src/openbao/mfa-totp.ts +21 -8
  127. package/src/openbao/pki-role.ts +19 -7
  128. package/src/openbao/plugin.ts +41 -15
  129. package/src/openbao/policy.ts +7 -7
  130. package/src/openbao/proxmox-role.ts +7 -16
  131. package/src/openbao/rename-adoption.test.ts +82 -0
  132. package/src/openbao/rename-families.test.ts +122 -0
  133. package/src/openbao/rename-identity.test.ts +129 -0
  134. package/src/openbao/rename-identity.ts +117 -0
  135. package/src/openbao/rename.ts +19 -15
  136. package/src/openbao/ssh-role-erase.ts +50 -0
  137. package/src/openbao/ssh-role.ts +16 -54
@@ -5,8 +5,9 @@
5
5
  * are credential issuance, not configuration, and are OUT OF SCOPE. This declares the
6
6
  * role shape an approle login inherits — policy names and TTL strings only.
7
7
  *
8
- * ★ `defaultRemovalPolicy: 'retain'` — deleting a role revokes every token bound to it.
9
- * Opt in with `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
8
+ * ★ `defaultRemovalPolicy: 'retain'` — deleting a role locks out every host that logs in with it:
9
+ * its secret_ids go with it (the ⚠️ on `delete` below). Opt in with
10
+ * `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
10
11
  */
11
12
  import { Resource } from 'alchemy';
12
13
  import { isResolved } from 'alchemy/Diff';
@@ -17,12 +18,12 @@ import {
17
18
  type BaoAuthRoleAttributes,
18
19
  type BaoAuthRoleProps,
19
20
  attributesOf,
20
- isRename,
21
21
  matches,
22
22
  readPath,
23
23
  writeBody,
24
24
  } from './auth-role-form.ts';
25
25
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
26
+ import { foldName, guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
26
27
 
27
28
  export type { BaoAuthRoleAttributes, BaoAuthRoleProps };
28
29
 
@@ -38,6 +39,14 @@ export const BaoAuthRole = Resource<BaoAuthRole>('Bao.AuthRole', {
38
39
  defaultRemovalPolicy: 'retain',
39
40
  });
40
41
 
42
+ /**
43
+ * ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
44
+ * v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key,
45
+ * :1537), so `Host` → `host` is the SAME role: a `replace` there would write it, then — under
46
+ * `destroy` — delete the old generation, which is that same role.
47
+ */
48
+ const IDENTITY = nameIdentity<BaoAuthRoleAttributes>('Bao.AuthRole', readPath, foldName);
49
+
41
50
  const readRole = (props: BaoAuthRoleProps) =>
42
51
  Effect.gen(function* () {
43
52
  const live = yield* baoRead(readPath(props.name));
@@ -65,26 +74,31 @@ export const BaoAuthRoleProvider = () =>
65
74
  * ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. A role edited in the OpenBao
66
75
  * UI is exactly the drift the check-* gates exist to catch.
67
76
  */
68
- diff: Effect.fn(function* ({ news, output }) {
69
- if (output === undefined || !isResolved(news)) return undefined;
77
+ diff: Effect.fn(function* ({ news, olds, output }) {
70
78
  /**
71
- * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`.
72
- * Until 2026-09-21 a new `name` read nothing, planned `update` and wrote the new role,
73
- * leaving the old one live under no state record: its secret_ids kept logging in for
74
- * their whole TTL, and no plan would ever mention it again (REPLACE.md).
79
+ * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`,
80
+ * judged before `isResolved(news)` (rename-identity.ts). Until 2026-09-21 a new `name`
81
+ * read nothing, planned `update` and wrote the new role, leaving the old one live
82
+ * under no state record: its secret_ids kept logging in for their whole TTL, and no
83
+ * plan would ever mention it again (REPLACE.md). ⛔ A rename onto a role that exists
84
+ * fails the plan.
75
85
  * ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL STAYS LIVE — retain keeps the old
76
86
  * generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
77
87
  * opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
78
88
  */
79
- // ⚠️ Case-insensitively, because the server is — the ⚠️ on isRename in auth-role-form.ts.
80
- if (isRename(output.name, news.name)) return { action: 'replace' } as const;
89
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
90
+ if (output === undefined) return undefined;
91
+ if (move !== undefined) return { action: 'replace' } as const;
92
+ if (!isResolved(news)) return undefined;
81
93
  const live = yield* readRole(news);
82
94
  if (live === undefined) return { action: 'update' } as const;
83
95
  if (matches(live, news)) return { action: 'noop' } as const;
84
96
  return { action: 'update' } as const;
85
97
  }),
86
98
 
87
- reconcile: Effect.fn(function* ({ news }) {
99
+ reconcile: Effect.fn(function* ({ news, output }) {
100
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
101
+ yield* guardRename(IDENTITY, news, output);
88
102
  const live = yield* readRole(news);
89
103
  if (live === undefined || !matches(live, news)) {
90
104
  yield* baoWrite('PUT', readPath(news.name), writeBody(news));
@@ -101,8 +115,15 @@ export const BaoAuthRoleProvider = () =>
101
115
  }),
102
116
 
103
117
  /**
104
- * ⛔ DELETING A ROLE REVOKES EVERY TOKEN BOUND TO IT. Idempotent as Alchemy requires
105
- * — already gone is success — but retain is the default for a reason.
118
+ * ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED. openbao v2.6.2 pathRoleDelete
119
+ * (builtin/credential/approle/path_role.go:1858-1902) deletes every secret_id
120
+ * (flushRoleSecrets, validation.go:420-446), the role_id mapping and the role, so no new
121
+ * login succeeds, and it revokes nothing. A token already issued lives to its TTL; only
122
+ * its renewal fails, because pathLoginRenew (path_login.go:414-438) refuses once the role
123
+ * is gone. So this is not a containment action: to contain a leaked token, revoke it or
124
+ * its accessor. (Corrected 2026-09-21: this said the delete revoked every token.)
125
+ * Idempotent as Alchemy requires — already gone is success — but retain is the default,
126
+ * because every host logging in with the role is locked out at the delete.
106
127
  */
107
128
  delete: Effect.fn(function* ({ output }) {
108
129
  yield* baoDelete(readPath(output.name));
@@ -38,13 +38,7 @@ 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
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
48
42
 
49
43
  export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
50
44
 
@@ -66,6 +60,9 @@ export const BaoCloudflareRole = Resource<BaoCloudflareRole>('Bao.CloudflareRole
66
60
  defaultRemovalPolicy: 'retain',
67
61
  });
68
62
 
63
+ /** Exact: a trailing `/` on the mount is the same path (`rolePath` trims it). */
64
+ const IDENTITY = roleIdentity<BaoCloudflareRoleAttributes>('Bao.CloudflareRole', rolePath);
65
+
69
66
  export const BaoCloudflareRoleProvider = () =>
70
67
  Provider.effect(
71
68
  BaoCloudflareRole,
@@ -131,9 +128,7 @@ export const BaoCloudflareRoleProvider = () =>
131
128
  * and the old id leaves the stack as an orphan delete, which `retain` keeps live.
132
129
  */
133
130
  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);
131
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
137
132
  if (output === undefined) return undefined;
138
133
  if (move !== undefined) return { action: 'replace' } as const;
139
134
  if (!isResolved(news)) return undefined;
@@ -153,10 +148,7 @@ export const BaoCloudflareRoleProvider = () =>
153
148
  reconcile: Effect.fn(function* ({ news, output }) {
154
149
  const path = rolePath(news.mount, news.name);
155
150
  // ⛔ 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
- }
151
+ yield* guardRename(IDENTITY, news, output);
160
152
  const refusal = refusalOf(news);
161
153
  if (refusal !== undefined) {
162
154
  return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Fakes of the stores behind the batch-2 families, for fake-bao: every role engine, JWT auth config
3
+ * and login-MFA enforcements (`wireRoles`), the TOTP method table (`totpMethods`) and the plugin
4
+ * catalog (`pluginCatalog`), behind one answer (`fakeEstate`). As in fake-engines.ts, each keys its
5
+ * objects the way the real server does, because a rename test is only as good as the fake's idea of
6
+ * "the same object".
7
+ *
8
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
9
+ */
10
+ import type { Reply, Seen } from './fake-bao.ts';
11
+ import { ABSENT, type Store, type Stored, lastSegment, store } from './fake-engines.ts';
12
+
13
+ /**
14
+ * The role stores whose server lowercases the name: AppRole (openbao v2.6.2
15
+ * builtin/credential/approle/path_role.go:1485 and :1893), Kubernetes
16
+ * (builtin/credential/kubernetes backend.go:362, path_role.go:250 and :396) and JWT, whose `name`
17
+ * field is a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77; sdk v2.6.2
18
+ * framework/field_data.go:250-255 lowercases it on read). PKI and SSH store the name verbatim. The
19
+ * tests mount all three at their default paths.
20
+ */
21
+ const FOLDED = /^auth\/(approle|jwt|kubernetes)\/role\/[^/]+$/;
22
+
23
+ const keyOf = (path: string): string =>
24
+ FOLDED.test(path) ? path.replace(/[^/]+$/, (name) => name.toLowerCase()) : path;
25
+
26
+ /** Fields a write sends comma-joined that the server reads back as a list. */
27
+ const LISTS = new Set([
28
+ 'allowed_domains',
29
+ 'cn_validations',
30
+ 'ext_key_usage',
31
+ 'key_usage',
32
+ 'token_policies',
33
+ ]);
34
+
35
+ /**
36
+ * The server's typed view of a `k=v` body: `true`, `false` and digit strings become values, and the
37
+ * LISTS split. Arrays, objects and duration strings pass through, and every reader here takes a
38
+ * duration string as well as seconds (`ttlSeconds`).
39
+ */
40
+ const typed = (body: Stored): Stored => {
41
+ const out: Stored = {};
42
+ for (const [field, value] of Object.entries(body)) {
43
+ if (typeof value !== 'string') out[field] = value;
44
+ else if (LISTS.has(field)) out[field] = value.split(',').filter((part) => part !== '');
45
+ else if (value === 'true' || value === 'false') out[field] = value === 'true';
46
+ else out[field] = /^\d+$/.test(value) ? Number(value) : value;
47
+ }
48
+ return out;
49
+ };
50
+
51
+ /** Every role engine, JWT auth config and MFA login enforcement. A write replaces the object. */
52
+ export const wireRoles = (): Store =>
53
+ store<Stored>(
54
+ (_path, body) => typed(body),
55
+ (path) => keyOf(path),
56
+ );
57
+
58
+ /**
59
+ * `sys/plugins/catalog/<type>/<name>`. A register names its version in the body; a read or a delete
60
+ * names it in `?version=` (plugin-form.ts versionedPath).
61
+ */
62
+ export const pluginCatalog = (): Store =>
63
+ store<Stored>(
64
+ (path, body) => ({ ...body, builtin: false, name: lastSegment(path.split('?')[0] ?? '') }),
65
+ (path, body) => {
66
+ const version = body?.['version'];
67
+ return typeof version === 'string' && version !== '' ? `${path}?version=${version}` : path;
68
+ },
69
+ );
70
+
71
+ /**
72
+ * `identity/mfa/method/totp`: an upsert by `method_name` (identity/mfa.go:159-230), listed with
73
+ * every other type under `identity/mfa/method?list=true` (mfa-wire.ts). Keyed by the server-made
74
+ * id.
75
+ */
76
+ export const totpMethods = (): Store => {
77
+ const live = new Map<string, Stored>();
78
+ let made = 0;
79
+ const answer = (seen: Seen): Reply => {
80
+ const path = seen.path.replace(/^\/v1\//, '');
81
+ if (seen.method === 'POST') {
82
+ const body = JSON.parse(seen.body) as Stored;
83
+ const name = body['method_name'];
84
+ const found = [...live].find(([, method]) => method['name'] === name)?.[0];
85
+ made += found === undefined ? 1 : 0;
86
+ const id = found ?? `method-${String(made)}`;
87
+ live.set(id, { ...body, id, name, namespace_path: '', type: 'totp' });
88
+ return { json: { data: { method_id: id } }, status: 200 };
89
+ }
90
+ if (seen.method === 'DELETE') {
91
+ live.delete(lastSegment(path));
92
+ return { status: 204 };
93
+ }
94
+ if (path !== 'identity/mfa/method?list=true' || live.size === 0) return ABSENT;
95
+ const info = Object.fromEntries(live);
96
+ return { json: { data: { key_info: info, keys: [...live.keys()] } }, status: 200 };
97
+ };
98
+ return Object.assign(answer, { live });
99
+ };
100
+
101
+ /** All three behind one answer, routed by path, for a stack that mixes the families. */
102
+ export const fakeEstate = () => {
103
+ const roles = wireRoles();
104
+ const plugins = pluginCatalog();
105
+ const totp = totpMethods();
106
+ const answer = (seen: Seen): Reply => {
107
+ if (seen.path.startsWith('/v1/identity/mfa/method')) return totp(seen);
108
+ if (seen.path.startsWith('/v1/sys/plugins/catalog/')) return plugins(seen);
109
+ return roles(seen);
110
+ };
111
+ return Object.assign(answer, { plugins, roles, totp });
112
+ };
113
+
114
+ export type Estate = ReturnType<typeof fakeEstate>;
@@ -2,7 +2,7 @@
2
2
  * Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
3
3
  * plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
4
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".
5
+ * same object". `store` is shared with fake-engines-roles.ts, which fakes the batch-2 families.
6
6
  *
7
7
  * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
8
  */
@@ -17,26 +17,33 @@ import { trimTrailing } from './mount-path.ts';
17
17
  import { BaoPolicyProvider } from './policy.ts';
18
18
  import { policyKey } from './rename.ts';
19
19
 
20
- type Stored = Record<string, unknown>;
20
+ export type Stored = Record<string, unknown>;
21
21
 
22
22
  /** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
23
23
  export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
24
24
 
25
- const ABSENT: Reply = { json: { errors: [] }, status: 404 };
25
+ export const ABSENT: Reply = { json: { errors: [] }, status: 404 };
26
26
 
27
- const store = (
28
- write: (path: string, body: Record<string, string>, before: Stored | undefined) => Stored,
29
- key: (path: string) => string = (path) => path,
27
+ /**
28
+ * A store of objects by API path. `key` turns a request path (and a write's body) into the key the
29
+ * server would store it under.
30
+ */
31
+ export const store = <B extends Stored = Record<string, string>>(
32
+ write: (path: string, body: B, before: Stored | undefined) => Stored,
33
+ key: (path: string, body?: B) => string = (path) => path,
30
34
  ): Store => {
31
35
  const live = new Map<string, Stored>();
32
36
  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>;
37
+ const raw = seen.path.replace(/^\/v1\//, '');
38
+ // ★ `bao write` sends PUT, and the identity endpoints are called with POST. Both write.
39
+ if (seen.method === 'PUT' || seen.method === 'POST') {
40
+ const body = JSON.parse(seen.body) as B;
41
+ const path = key(raw, body);
36
42
  live.set(path, write(path, body, live.get(path)));
37
43
  return { status: 204 };
38
44
  }
39
- // ★ A delete answers 204 whether or not the object existed, as all three do.
45
+ const path = key(raw);
46
+ // ★ A delete answers 204 whether or not the object existed, as every engine here does.
40
47
  if (seen.method === 'DELETE') {
41
48
  live.delete(path);
42
49
  return { status: 204 };
@@ -47,7 +54,7 @@ const store = (
47
54
  return Object.assign(answer, { live });
48
55
  };
49
56
 
50
- const lastSegment = (path: string) => path.split('/').at(-1) ?? '';
57
+ export const lastSegment = (path: string): string => path.split('/').at(-1) ?? '';
51
58
 
52
59
  /**
53
60
  * `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The nine batch-2 families as rows of one table, so a rename test can run each of them through
3
+ * Alchemy's own plan and apply (fake-stack.ts) against one fake (fake-engines-roles.ts). Every row
4
+ * declares a resource whose identity is `name` (for Bao.JwtAuthConfig, the mount), with `knob`, a
5
+ * duration, in a prop that is NOT its identity: the pending Output in "a rename lands with another
6
+ * pending prop".
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
11
+ import type * as Output from 'alchemy/Output';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+ import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
16
+ import type { Seen } from './fake-bao.ts';
17
+ import { type Estate, fakeEstate } from './fake-engines-roles.ts';
18
+ import { type FakeStack, type StackBody, withFakeStack } from './fake-stack.ts';
19
+ import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
20
+ import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
21
+ import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
22
+ import { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
23
+ import { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
24
+ import { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
25
+ import { BaoPlugin, BaoPluginProvider } from './plugin.ts';
26
+ import { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
27
+
28
+ export const familyProviders = Layer.mergeAll(
29
+ BaoAuthRoleProvider(),
30
+ BaoJwtAuthConfigProvider(),
31
+ BaoJwtRoleProvider(),
32
+ BaoKubernetesRoleProvider(),
33
+ BaoMfaLoginEnforcementProvider(),
34
+ BaoMfaTotpMethodProvider(),
35
+ BaoPkiRoleProvider(),
36
+ BaoPluginProvider(),
37
+ BaoSshRoleProvider(),
38
+ FetchHttpClient.layer,
39
+ );
40
+
41
+ /** One stack over a fresh estate: every family's provider, and every call the fake saw. */
42
+ export const withEstate = (
43
+ body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
44
+ ): Promise<void> => {
45
+ const estate = fakeEstate();
46
+ return withFakeStack(familyProviders, estate, (stack, bao) => body(stack, estate, bao.seen));
47
+ };
48
+
49
+ /** The row for one family, by name. */
50
+ export const rowOf = (family: string): Family => {
51
+ const row = FAMILIES.find((each) => each.family === family);
52
+ if (row === undefined) throw new Error(`no fake-families row for ${family}`);
53
+ return row;
54
+ };
55
+
56
+ type Text = string | Output.Output<string>;
57
+ type Removal = typeof RemovalPolicy.destroy;
58
+
59
+ export interface Family {
60
+ readonly family: string;
61
+ /** One resource, logical id `id`, whose identity is `name`. */
62
+ readonly declare: (id: string, name: Text, knob: Text, removal?: Removal) => StackBody;
63
+ /** Whether the fake holds the object `name` names. */
64
+ readonly has: (estate: Estate, name: string) => boolean;
65
+ }
66
+
67
+ const role = (path: (name: string) => string) => (estate: Estate, name: string) =>
68
+ estate.roles.live.has(path(name));
69
+
70
+ const APPROLE = { secretIdTtl: '24h', tokenMaxTtl: '1h', tokenPolicies: ['default'] } as const;
71
+ const SHA = 'a'.repeat(64);
72
+
73
+ export const FAMILIES: readonly Family[] = [
74
+ {
75
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
76
+ BaoAuthRole(id, { ...APPROLE, name, tokenTtl: knob }).pipe(removal()),
77
+ family: 'Bao.AuthRole',
78
+ has: role((name) => `auth/approle/role/${name}`),
79
+ },
80
+ {
81
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
82
+ BaoPkiRole(id, { allowedDomains: ['example.com'], maxTtl: '8760h', name, ttl: knob }).pipe(
83
+ removal(),
84
+ ),
85
+ family: 'Bao.PkiRole',
86
+ has: role((name) => `pki/roles/${name}`),
87
+ },
88
+ {
89
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
90
+ BaoJwtRole(id, {
91
+ boundAudiences: ['https://example.com'],
92
+ name,
93
+ roleType: 'jwt',
94
+ tokenPolicies: ['ci'],
95
+ tokenTtl: knob,
96
+ userClaim: 'sub',
97
+ }).pipe(removal()),
98
+ family: 'Bao.JwtRole',
99
+ has: role((name) => `auth/jwt/role/${name}`),
100
+ },
101
+ {
102
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
103
+ BaoKubernetesRole(id, {
104
+ aliasNameSource: 'serviceaccount_uid',
105
+ boundServiceAccountNames: ['app'],
106
+ boundServiceAccountNamespaces: ['apps'],
107
+ name,
108
+ tokenPolicies: ['app'],
109
+ tokenTtl: knob,
110
+ }).pipe(removal()),
111
+ family: 'Bao.KubernetesRole',
112
+ has: role((name) => `auth/kubernetes/role/${name}`),
113
+ },
114
+ {
115
+ declare: (id, mount, knob, removal = RemovalPolicy.retain) =>
116
+ BaoJwtAuthConfig(id, {
117
+ boundIssuer: knob,
118
+ jwksUrl: 'https://issuer.example.com/keys',
119
+ mount,
120
+ }).pipe(removal()),
121
+ family: 'Bao.JwtAuthConfig',
122
+ has: role((mount) => `auth/${mount}/config`),
123
+ },
124
+ {
125
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
126
+ BaoMfaTotpMethod(id, { issuer: knob, name }).pipe(removal()),
127
+ family: 'Bao.MfaTotpMethod',
128
+ has: (estate, name) => [...estate.totp.live.values()].some((each) => each['name'] === name),
129
+ },
130
+ {
131
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
132
+ BaoMfaLoginEnforcement(id, {
133
+ authMethodTypes: ['jwt'],
134
+ mfaMethodIds: [knob],
135
+ name,
136
+ }).pipe(removal()),
137
+ family: 'Bao.MfaLoginEnforcement',
138
+ has: role((name) => `identity/mfa/login-enforcement/${name}`),
139
+ },
140
+ {
141
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
142
+ BaoSshRole(id, {
143
+ allowUserCertificates: true,
144
+ allowedUsers: ['ops'],
145
+ maxTtl: '8h',
146
+ name,
147
+ ttl: knob,
148
+ }).pipe(removal()),
149
+ family: 'Bao.SshRole',
150
+ has: role((name) => `ssh/roles/${name}`),
151
+ },
152
+ {
153
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
154
+ BaoPlugin(id, {
155
+ args: [knob],
156
+ command: 'plugin-example',
157
+ name,
158
+ sha256: SHA,
159
+ type: 'secret',
160
+ version: 'v1.0.0',
161
+ }).pipe(removal()),
162
+ family: 'Bao.Plugin',
163
+ has: (estate, name) =>
164
+ estate.plugins.live.has(`sys/plugins/catalog/secret/${name}?version=v1.0.0`),
165
+ },
166
+ ];
167
+
168
+ /**
169
+ * Two of one family, `X` and `Y`, under destroy: the shape a swap or a shift needs. Their knobs
170
+ * differ, so a generation that lands on the other's name has to write over it.
171
+ */
172
+ export const pairOf =
173
+ (row: Family) =>
174
+ (x: string, y: string): StackBody =>
175
+ Effect.gen(function* () {
176
+ yield* row.declare('X', x, '15m', RemovalPolicy.destroy);
177
+ yield* row.declare('Y', y, '30m', RemovalPolicy.destroy);
178
+ });
179
+
180
+ /** An AppRole whose `tokenTtl` attribute is the Output the other rows are fed. */
181
+ export const upstream = (ttl: string) =>
182
+ BaoAuthRole('Upstream', { ...APPROLE, name: 'upstream', tokenTtl: ttl });
@@ -80,9 +80,21 @@ describe('hostAppRoles', () => {
80
80
  }
81
81
  });
82
82
 
83
- it('refuses unknown classes, duplicate hosts and names that would blur the separator', () => {
83
+ it('gives a host in two classes one role per class', () => {
84
+ const hosts = [
85
+ { class: 'pve-node', name: 'node-c' },
86
+ { class: 'operator-signer', name: 'node-c' },
87
+ ];
88
+ assert.deepEqual(
89
+ hostAppRoles({ ...INPUT, hosts }).map((role) => role.name),
90
+ ['operator-signer--node-c', 'pve-node--node-c'],
91
+ );
92
+ });
93
+
94
+ it('refuses unknown classes, a host twice in one class and names that blur the separator', () => {
84
95
  const bad = [
85
96
  { class: 'nope', name: 'node-c' },
97
+ { class: 'constructor', name: 'node-d' },
86
98
  { class: 'pve-node', name: 'node-a' },
87
99
  { class: 'pve-node', name: 'node-a' },
88
100
  { class: 'pve-node', name: 'node--x' },
@@ -93,7 +105,8 @@ describe('hostAppRoles', () => {
93
105
  (error: unknown) => {
94
106
  assert.ok(error instanceof Error);
95
107
  assert.match(error.message, /unknown class `nope`/);
96
- assert.match(error.message, /`node-a` is listed twice/);
108
+ assert.match(error.message, /unknown class `constructor`/);
109
+ assert.match(error.message, /`node-a` is listed twice in class `pve-node`/);
97
110
  assert.match(error.message, /`node--x` is not lowercase/);
98
111
  assert.match(error.message, /`Node-Y` is not lowercase/);
99
112
  return true;
@@ -96,22 +96,37 @@ export const hostAppRoles = (input: HostAppRolesInput): readonly BaoAuthRoleProp
96
96
  const problems = Object.entries(input.classes).flatMap(([name, spec]) =>
97
97
  classProblems(name, spec),
98
98
  );
99
+ /**
100
+ * ⛔ DEDUPED ON THE ROLE NAME, NOT THE HOST NAME. A host may sit in several classes (one machine
101
+ * that is both a signer and a node), and each class gives it its own role: `<class>--<host>`
102
+ * differs, so nothing collides. Until 2026-09-21 this keyed on the host alone and refused that
103
+ * estate outright. What it must still refuse is the same host twice in ONE class: two
104
+ * declarations of one role name, so one AppRole that two resources would each claim.
105
+ */
99
106
  const seen = new Set<string>();
100
107
  const roles: BaoAuthRoleProps[] = [];
101
108
  for (const host of input.hosts) {
102
109
  if (!NAME.test(host.name)) {
103
110
  problems.push(`host \`${host.name}\` is not lowercase letters, digits, single hyphens`);
104
111
  }
105
- if (seen.has(host.name)) problems.push(`host \`${host.name}\` is listed twice`);
106
- seen.add(host.name);
107
- const spec = input.classes[host.class];
112
+ const name = hostRoleName(host.class, host.name);
113
+ if (seen.has(name)) {
114
+ problems.push(`host \`${host.name}\` is listed twice in class \`${host.class}\``);
115
+ }
116
+ seen.add(name);
117
+ /**
118
+ * ⛔ OWN KEYS ONLY. `classes['constructor']` is Object's constructor, not undefined, so a host
119
+ * naming it passed as a class with no policies and no secretIdTtl, never checked by
120
+ * classProblems, and wrote a role whose secret_id never expires.
121
+ */
122
+ const spec = Object.hasOwn(input.classes, host.class) ? input.classes[host.class] : undefined;
108
123
  if (spec === undefined) {
109
124
  problems.push(`host \`${host.name}\` names unknown class \`${host.class}\``);
110
125
  continue;
111
126
  }
112
127
  roles.push({
113
128
  bindSecretId: true,
114
- name: hostRoleName(host.class, host.name),
129
+ name,
115
130
  secretIdNumUses: spec.secretIdNumUses ?? 0,
116
131
  secretIdTtl: spec.secretIdTtl,
117
132
  tokenMaxTtl: spec.tokenMaxTtl,
@@ -10,6 +10,11 @@
10
10
  *
11
11
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
12
12
  * the old one keeps its own until that mount is disabled. There is no identity but the mount.
13
+ * ⛔ A MOVE ONTO A MOUNT WHOSE CONFIG EXISTS FAILS THE PLAN, like every other family's move onto an
14
+ * occupied path (rename-identity.ts). With no delete, nothing is lost to a swap, which MEASURED
15
+ * 2026-09-21 ended with each config where it was declared. But the new generation writes over
16
+ * the config it lands on, which changes who can log in on that mount: an issuer or key set
17
+ * someone else manages, silently replaced. Move through a mount with no config instead.
13
18
  */
14
19
  import { Resource } from 'alchemy';
15
20
  import { isResolved } from 'alchemy/Diff';
@@ -22,11 +27,12 @@ import {
22
27
  attributesOf,
23
28
  configPath,
24
29
  matches,
25
- mountOf,
26
30
  problems,
27
31
  wouldErase,
28
32
  writeBody,
29
33
  } from './jwt-config-form.ts';
34
+ import { declaredOr } from './rename.ts';
35
+ import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
30
36
  import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
31
37
 
32
38
  export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
@@ -43,6 +49,16 @@ export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig',
43
49
  defaultRemovalPolicy: 'retain',
44
50
  });
45
51
 
52
+ /** The identity is the mount alone; a trailing `/` is the same mount (`configPath` trims it). */
53
+ const IDENTITY: Identity<BaoJwtAuthConfigAttributes> = {
54
+ declared: (props) => {
55
+ const mount = declaredOr(props, 'mount', 'jwt');
56
+ return mount === undefined ? undefined : configPath({ mount });
57
+ },
58
+ family: 'Bao.JwtAuthConfig',
59
+ recorded: (output) => configPath(output),
60
+ };
61
+
46
62
  export const jwtConfigSpec = (
47
63
  props: BaoJwtAuthConfigProps,
48
64
  ): RoleSpec<BaoJwtAuthConfigAttributes> => ({
@@ -67,14 +83,19 @@ export const BaoJwtAuthConfigProvider = () =>
67
83
  return found?.attributes;
68
84
  }),
69
85
 
70
- diff: Effect.fn(function* ({ news, output }) {
71
- if (output === undefined || !isResolved(news)) return undefined;
72
- if (mountOf(news) !== output.mount) return { action: 'replace' } as const;
86
+ diff: Effect.fn(function* ({ news, olds, output }) {
87
+ // ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
88
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
89
+ if (output === undefined) return undefined;
90
+ if (move !== undefined) return { action: 'replace' } as const;
91
+ if (!isResolved(news)) return undefined;
73
92
  return { action: yield* planRole(jwtConfigSpec(news)) } as const;
74
93
  }),
75
94
 
76
95
  /** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
77
- reconcile: Effect.fn(function* ({ news }) {
96
+ reconcile: Effect.fn(function* ({ news, output }) {
97
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
98
+ yield* guardRename(IDENTITY, news, output);
78
99
  return yield* reconcileRole(jwtConfigSpec(news));
79
100
  }),
80
101