@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
|
@@ -80,9 +80,21 @@ describe('hostAppRoles', () => {
|
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it('
|
|
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,
|
|
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
|
-
|
|
106
|
-
seen.
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
package/src/openbao/jwt-role.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
|
|
16
16
|
* path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
|
|
17
|
-
* stays live, and still admits logins, until removed by hand.
|
|
17
|
+
* stays live, and still admits logins, until removed by hand. ⛔ A move onto a role that already
|
|
18
|
+
* exists fails the plan (rename-identity.ts).
|
|
18
19
|
* ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
|
|
19
20
|
*/
|
|
20
21
|
import { Resource } from 'alchemy';
|
|
@@ -28,11 +29,11 @@ import {
|
|
|
28
29
|
type BaoJwtRoleProps,
|
|
29
30
|
attributesOf,
|
|
30
31
|
matches,
|
|
31
|
-
mountOf,
|
|
32
32
|
problems,
|
|
33
33
|
rolePath,
|
|
34
34
|
writeBody,
|
|
35
35
|
} from './jwt-role-form.ts';
|
|
36
|
+
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
36
37
|
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
37
38
|
|
|
38
39
|
export type {
|
|
@@ -54,6 +55,18 @@ export const BaoJwtRole = Resource<BaoJwtRole>('Bao.JwtRole', {
|
|
|
54
55
|
defaultRemovalPolicy: 'retain',
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The name folded, as the server keys it (`foldName`): `name` is a TypeLowerCaseString
|
|
60
|
+
* (builtin/credential/jwt/path_role.go:77), so the handler reads it lowercased before it ever builds
|
|
61
|
+
* `role/<name>` (:289). `App` → `app` is the same role, never a move; `problems` refuses the case.
|
|
62
|
+
*/
|
|
63
|
+
const IDENTITY = roleIdentity<BaoJwtRoleAttributes>(
|
|
64
|
+
'Bao.JwtRole',
|
|
65
|
+
(mount, name) => rolePath({ mount, name }),
|
|
66
|
+
'jwt',
|
|
67
|
+
foldName,
|
|
68
|
+
);
|
|
69
|
+
|
|
57
70
|
export const jwtRoleSpec = (props: BaoJwtRoleProps): RoleSpec<BaoJwtRoleAttributes> => ({
|
|
58
71
|
attributesOf: (live) => attributesOf(props, live),
|
|
59
72
|
body: writeBody(props),
|
|
@@ -80,15 +93,18 @@ export const BaoJwtRoleProvider = () =>
|
|
|
80
93
|
}),
|
|
81
94
|
|
|
82
95
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
|
|
83
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
96
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
97
|
+
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
98
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
99
|
+
if (output === undefined) return undefined;
|
|
100
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
101
|
+
if (!isResolved(news)) return undefined;
|
|
88
102
|
return { action: yield* planRole(jwtRoleSpec(news)) } as const;
|
|
89
103
|
}),
|
|
90
104
|
|
|
91
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
105
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
106
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
107
|
+
yield* guardRename(IDENTITY, news, output);
|
|
92
108
|
return yield* reconcileRole(jwtRoleSpec(news));
|
|
93
109
|
}),
|
|
94
110
|
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* and nothing else (path_role.go:239-256).
|
|
10
10
|
*
|
|
11
11
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
|
|
12
|
-
* path cannot collide). Under the default `retain` the old role stays live until
|
|
12
|
+
* path cannot collide with the old). Under the default `retain` the old role stays live until
|
|
13
|
+
* removed by hand. ⛔ A move onto a role that already exists fails the plan (rename-identity.ts).
|
|
13
14
|
*/
|
|
14
15
|
import { Resource } from 'alchemy';
|
|
15
16
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -22,11 +23,11 @@ import {
|
|
|
22
23
|
type BaoKubernetesRoleProps,
|
|
23
24
|
attributesOf,
|
|
24
25
|
matches,
|
|
25
|
-
mountOf,
|
|
26
26
|
problems,
|
|
27
27
|
rolePath,
|
|
28
28
|
writeBody,
|
|
29
29
|
} from './kubernetes-role-form.ts';
|
|
30
|
+
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
30
31
|
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
31
32
|
|
|
32
33
|
export type {
|
|
@@ -47,6 +48,14 @@ export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole
|
|
|
47
48
|
defaultRemovalPolicy: 'retain',
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
/** The name folded, as the server stores it (`foldName`). */
|
|
52
|
+
const IDENTITY = roleIdentity<BaoKubernetesRoleAttributes>(
|
|
53
|
+
'Bao.KubernetesRole',
|
|
54
|
+
(mount, name) => rolePath({ mount, name }),
|
|
55
|
+
'kubernetes',
|
|
56
|
+
foldName,
|
|
57
|
+
);
|
|
58
|
+
|
|
50
59
|
export const kubernetesRoleSpec = (
|
|
51
60
|
props: BaoKubernetesRoleProps,
|
|
52
61
|
): RoleSpec<BaoKubernetesRoleAttributes> => ({
|
|
@@ -72,15 +81,18 @@ export const BaoKubernetesRoleProvider = () =>
|
|
|
72
81
|
}),
|
|
73
82
|
|
|
74
83
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
|
|
75
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
84
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
85
|
+
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
86
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
87
|
+
if (output === undefined) return undefined;
|
|
88
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
89
|
+
if (!isResolved(news)) return undefined;
|
|
80
90
|
return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
|
|
81
91
|
}),
|
|
82
92
|
|
|
83
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
93
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
94
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
95
|
+
yield* guardRename(IDENTITY, news, output);
|
|
84
96
|
return yield* reconcileRole(kubernetesRoleSpec(news));
|
|
85
97
|
}),
|
|
86
98
|
|
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
|
|
20
20
|
* live — which is what the server would do anyway.
|
|
21
21
|
*
|
|
22
|
-
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first. Under `retain` BOTH
|
|
23
|
+
* enforcements then apply — fail closed. Under `destroy` the old one's delete is refused, so the
|
|
24
|
+
* deploy stops after creating. ⛔ A rename onto an enforcement that exists fails the plan
|
|
25
|
+
* (rename-identity.ts): the upsert by name would otherwise rewrite, in place, what someone else's
|
|
26
|
+
* enforcement demands. MEASURED 2026-09-21: a swap of two under `destroy` wrote each over the
|
|
27
|
+
* other, then stopped on the two refused deletes.
|
|
25
28
|
*/
|
|
26
29
|
import { Resource } from 'alchemy';
|
|
27
30
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -38,7 +41,8 @@ import {
|
|
|
38
41
|
problems,
|
|
39
42
|
writeBody,
|
|
40
43
|
} from './mfa-enforcement-form.ts';
|
|
41
|
-
import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
|
|
44
|
+
import { authAccessors, enforcementPath, readEnforcement, writeEnforcement } from './mfa-wire.ts';
|
|
45
|
+
import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
|
|
42
46
|
|
|
43
47
|
export type {
|
|
44
48
|
BaoMfaLoginEnforcementAttributes,
|
|
@@ -59,6 +63,12 @@ export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaL
|
|
|
59
63
|
|
|
60
64
|
type Env = HttpClient.HttpClient;
|
|
61
65
|
|
|
66
|
+
/** Exact: the identity store indexes enforcements by (namespace, name) as given. */
|
|
67
|
+
const IDENTITY = nameIdentity<BaoMfaLoginEnforcementAttributes>(
|
|
68
|
+
'Bao.MfaLoginEnforcement',
|
|
69
|
+
enforcementPath,
|
|
70
|
+
);
|
|
71
|
+
|
|
62
72
|
const refuse = (name: string, message: string): Effect.Effect<never> =>
|
|
63
73
|
Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
|
|
64
74
|
|
|
@@ -119,13 +129,18 @@ export const BaoMfaLoginEnforcementProvider = () =>
|
|
|
119
129
|
}),
|
|
120
130
|
|
|
121
131
|
/** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
|
|
122
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
133
|
+
// ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
|
|
134
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
135
|
+
if (output === undefined) return undefined;
|
|
136
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
137
|
+
if (!isResolved(news)) return undefined;
|
|
125
138
|
return { action: yield* planEnforcement(news) } as const;
|
|
126
139
|
}),
|
|
127
140
|
|
|
128
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
141
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
142
|
+
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
143
|
+
yield* guardRename(IDENTITY, news, output);
|
|
129
144
|
return yield* reconcileEnforcement(news);
|
|
130
145
|
}),
|
|
131
146
|
|
package/src/openbao/mfa-totp.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
writeBody,
|
|
32
32
|
} from './mfa-totp-form.ts';
|
|
33
33
|
import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
|
|
34
|
+
import { declaredString } from './rename.ts';
|
|
34
35
|
|
|
35
36
|
export type {
|
|
36
37
|
BaoMfaTotpMethodAttributes,
|
|
@@ -50,8 +51,8 @@ export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod',
|
|
|
50
51
|
defaultRemovalPolicy: 'retain',
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
const refuse = (
|
|
54
|
-
Effect.die(new Error(`Bao.MfaTotpMethod ${
|
|
54
|
+
const refuse = (name: string, message: string): Effect.Effect<never> =>
|
|
55
|
+
Effect.die(new Error(`Bao.MfaTotpMethod ${name}: ${message}`));
|
|
55
56
|
|
|
56
57
|
export const readTotp = (
|
|
57
58
|
props: BaoMfaTotpMethodProps,
|
|
@@ -75,13 +76,13 @@ export const reconcileTotp = (
|
|
|
75
76
|
): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
|
|
76
77
|
Effect.gen(function* () {
|
|
77
78
|
const bad = problems(props);
|
|
78
|
-
if (bad.length > 0) return yield* refuse(props, bad.join('; '));
|
|
79
|
+
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
79
80
|
const live = yield* readTotp(props);
|
|
80
81
|
if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
|
|
81
82
|
const after = yield* readTotp(props);
|
|
82
83
|
if (after === undefined)
|
|
83
|
-
return yield* refuse(props, 'the write succeeded but no method has that name.');
|
|
84
|
-
if (!matches(after, props)) return yield* refuse(props, 'the method read back different.');
|
|
84
|
+
return yield* refuse(props.name, 'the write succeeded but no method has that name.');
|
|
85
|
+
if (!matches(after, props)) return yield* refuse(props.name, 'the method read back different.');
|
|
85
86
|
return after;
|
|
86
87
|
});
|
|
87
88
|
|
|
@@ -97,14 +98,26 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
97
98
|
return yield* readTotp(olds);
|
|
98
99
|
}),
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* ⛔ THE NAME IS JUDGED BEFORE `isResolved(news)`. Until 2026-09-21 it came after, so a
|
|
103
|
+
* rename landing with a pending `issuer` (or any other Output) planned `update`, and the
|
|
104
|
+
* upsert by name wrote a SECOND method: the header's stranding, reached through a green
|
|
105
|
+
* plan. MEASURED through the engine (rename-families.test.ts).
|
|
106
|
+
*/
|
|
100
107
|
diff: Effect.fn(function* ({ news, output }) {
|
|
108
|
+
const name = declaredString(news, 'name');
|
|
109
|
+
if (output !== undefined && name !== undefined) {
|
|
110
|
+
const rename = renameProblem(output.name, name);
|
|
111
|
+
if (rename !== undefined) return yield* refuse(name, rename);
|
|
112
|
+
}
|
|
101
113
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
102
|
-
const rename = renameProblem(output.name, news.name);
|
|
103
|
-
if (rename !== undefined) return yield* refuse(news, rename);
|
|
104
114
|
return { action: yield* planTotp(news) } as const;
|
|
105
115
|
}),
|
|
106
116
|
|
|
107
|
-
|
|
117
|
+
/** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
|
|
118
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
119
|
+
const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
|
|
120
|
+
if (rename !== undefined) return yield* refuse(news.name, rename);
|
|
108
121
|
return yield* reconcileTotp(news);
|
|
109
122
|
}),
|
|
110
123
|
|
package/src/openbao/pki-role.ts
CHANGED
|
@@ -44,7 +44,6 @@ import * as Provider from 'alchemy/Provider';
|
|
|
44
44
|
import * as Effect from 'effect/Effect';
|
|
45
45
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
46
46
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
47
|
-
import { mountPath } from './mount-form.ts';
|
|
48
47
|
import {
|
|
49
48
|
type BaoPkiRoleAttributes,
|
|
50
49
|
type BaoPkiRoleProps,
|
|
@@ -54,6 +53,7 @@ import {
|
|
|
54
53
|
rolePath,
|
|
55
54
|
writeBody,
|
|
56
55
|
} from './pki-role-form.ts';
|
|
56
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
57
57
|
|
|
58
58
|
export type { BaoPkiRoleAttributes, BaoPkiRoleProps };
|
|
59
59
|
|
|
@@ -69,6 +69,13 @@ export const BaoPkiRole = Resource<BaoPkiRole>('Bao.PkiRole', {
|
|
|
69
69
|
defaultRemovalPolicy: 'retain',
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
/** Exact: the engine stores `role/<name>` verbatim (builtin/logical/pki/path_roles.go:963). */
|
|
73
|
+
const IDENTITY = roleIdentity<BaoPkiRoleAttributes>(
|
|
74
|
+
'Bao.PkiRole',
|
|
75
|
+
(mount, name) => rolePath({ mount, name }),
|
|
76
|
+
'pki',
|
|
77
|
+
);
|
|
78
|
+
|
|
72
79
|
const readRole = (props: BaoPkiRoleProps) =>
|
|
73
80
|
Effect.gen(function* () {
|
|
74
81
|
const live = yield* baoRead(rolePath(props));
|
|
@@ -100,18 +107,21 @@ export const BaoPkiRoleProvider = () =>
|
|
|
100
107
|
* fields is also what stops a field being added to props and quietly forgotten in
|
|
101
108
|
* the comparison.
|
|
102
109
|
*/
|
|
103
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
104
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
110
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
105
111
|
/**
|
|
106
112
|
* ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
|
|
107
113
|
* engine is a different object under a different CA, never an in-place edit —
|
|
108
|
-
* `replace`, so Alchemy creates the new one and retires the old.
|
|
114
|
+
* `replace`, so Alchemy creates the new one and retires the old. Judged before
|
|
115
|
+
* `isResolved(news)`, and ⛔ a move onto a role that exists fails the plan
|
|
116
|
+
* (rename-identity.ts).
|
|
109
117
|
* ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
|
|
110
118
|
* so a new `name` read nothing at the new path, planned `update`, wrote the new role,
|
|
111
119
|
* and left the old one issuing certificates under no state record at all.
|
|
112
120
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
122
|
+
if (output === undefined) return undefined;
|
|
123
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
124
|
+
if (!isResolved(news)) return undefined;
|
|
115
125
|
/**
|
|
116
126
|
* ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
|
|
117
127
|
* allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
|
|
@@ -127,8 +137,10 @@ export const BaoPkiRoleProvider = () =>
|
|
|
127
137
|
: ({ action: 'update' } as const);
|
|
128
138
|
}),
|
|
129
139
|
|
|
130
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
140
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
131
141
|
const path = rolePath(news);
|
|
142
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
143
|
+
yield* guardRename(IDENTITY, news, output);
|
|
132
144
|
/**
|
|
133
145
|
* ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
|
|
134
146
|
* false on every live role, so a role with no allowed domains can issue NOTHING —
|
package/src/openbao/plugin.ts
CHANGED
|
@@ -24,9 +24,18 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
24
24
|
import * as Provider from 'alchemy/Provider';
|
|
25
25
|
import * as Effect from 'effect/Effect';
|
|
26
26
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
type BaoPluginAttributes,
|
|
29
|
+
type BaoPluginProps,
|
|
30
|
+
type BaoPluginType,
|
|
31
|
+
matches,
|
|
32
|
+
resolve,
|
|
33
|
+
versionedPath,
|
|
34
|
+
} from './plugin-form.ts';
|
|
28
35
|
import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
|
|
29
36
|
import { deregisterPlugin } from './plugin-wire.ts';
|
|
37
|
+
import { declaredOr, declaredString } from './rename.ts';
|
|
38
|
+
import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
|
|
30
39
|
|
|
31
40
|
export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
|
|
32
41
|
|
|
@@ -42,6 +51,24 @@ export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
|
|
|
42
51
|
defaultRemovalPolicy: 'retain',
|
|
43
52
|
});
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so the identity is the versioned path a read and a
|
|
56
|
+
* delete address. Exact: the catalog stores `<type>/<name>/<version>` as given, and the version
|
|
57
|
+
* is already canonical (`problems`). ⚠️ An unversioned name that is a builtin reads back as
|
|
58
|
+
* present (plugin-wire.ts), so a move onto one fails the plan, as reconcile would refuse anyway.
|
|
59
|
+
*/
|
|
60
|
+
const IDENTITY: Identity<BaoPluginAttributes> = {
|
|
61
|
+
declared: (props) => {
|
|
62
|
+
const type = declaredString(props, 'type') as BaoPluginType | undefined;
|
|
63
|
+
const name = declaredString(props, 'name');
|
|
64
|
+
const version = declaredOr(props, 'version', '');
|
|
65
|
+
if (type === undefined || name === undefined || version === undefined) return undefined;
|
|
66
|
+
return versionedPath(type, name, version);
|
|
67
|
+
},
|
|
68
|
+
family: 'Bao.Plugin',
|
|
69
|
+
recorded: (output) => versionedPath(output.type, output.name, output.version),
|
|
70
|
+
};
|
|
71
|
+
|
|
45
72
|
export const BaoPluginProvider = () =>
|
|
46
73
|
Provider.effect(
|
|
47
74
|
BaoPlugin,
|
|
@@ -60,21 +87,18 @@ export const BaoPluginProvider = () =>
|
|
|
60
87
|
}),
|
|
61
88
|
|
|
62
89
|
/** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
|
|
63
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
64
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
65
|
-
const form = resolve(news);
|
|
90
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
66
91
|
/**
|
|
67
|
-
* ⛔ NAME, TYPE
|
|
68
|
-
*
|
|
69
|
-
* state record that no longer names it.
|
|
92
|
+
* ⛔ A NEW NAME, TYPE OR VERSION IS A DIFFERENT ENTRY, not an edit: `replace`, judged
|
|
93
|
+
* before `isResolved(news)`. Writing the new key in place would leave the old
|
|
94
|
+
* registration under a state record that no longer names it. ⛔ A move onto an entry
|
|
95
|
+
* that is already registered fails the plan (rename-identity.ts).
|
|
70
96
|
*/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
return { action: 'replace' } as const;
|
|
77
|
-
}
|
|
97
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
98
|
+
if (output === undefined) return undefined;
|
|
99
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
100
|
+
if (!isResolved(news)) return undefined;
|
|
101
|
+
const form = resolve(news);
|
|
78
102
|
const found = yield* readEntry(form);
|
|
79
103
|
if (found === undefined) return { action: 'update' } as const;
|
|
80
104
|
return matches(found.attributes, form)
|
|
@@ -83,7 +107,9 @@ export const BaoPluginProvider = () =>
|
|
|
83
107
|
}),
|
|
84
108
|
|
|
85
109
|
/** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
|
|
86
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
110
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
111
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
112
|
+
yield* guardRename(IDENTITY, news, output);
|
|
87
113
|
return yield* reconcilePlugin(news);
|
|
88
114
|
}),
|
|
89
115
|
|