@homeflare/alchemy 0.30.0 → 0.31.1
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 +22 -0
- package/dist/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- package/dist/proxmox/acl-wire.d.ts +26 -0
- package/dist/proxmox/acl-wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +28 -26
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +48 -89
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +2 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/client.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +27 -0
- package/dist/proxmox/credential-errors.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +0 -38
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/distilled-guard.d.ts +31 -0
- package/dist/proxmox/distilled-guard.d.ts.map +1 -0
- package/dist/proxmox/distilled-pve.d.ts +11 -4
- package/dist/proxmox/distilled-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts +29 -0
- package/dist/proxmox/group-wire.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +20 -24
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +725 -509
- package/dist/proxmox/index.js.map +40 -33
- package/dist/proxmox/lease-cache.d.ts +1 -1
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +50 -0
- package/dist/proxmox/mint.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +2 -2
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +4 -4
- package/dist/proxmox/role-wire.d.ts +28 -0
- package/dist/proxmox/role-wire.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +36 -52
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +50 -0
- package/dist/proxmox/unreadable-read.d.ts.map +1 -0
- package/dist/proxmox/user-wire.d.ts +18 -0
- package/dist/proxmox/user-wire.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +25 -32
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +2 -2
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/docs/credentials.md +9 -0
- package/docs/discord.md +129 -0
- package/docs/upstream-conformance.md +17 -1
- package/package.json +5 -1
- package/src/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -0
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +29 -82
- package/src/proxmox/adopt-noop.test.ts +18 -0
- package/src/proxmox/api-token-adopt.test.ts +28 -12
- package/src/proxmox/api-token-form.ts +50 -93
- package/src/proxmox/api-token.ts +149 -135
- package/src/proxmox/client.ts +2 -7
- package/src/proxmox/constraints-forms.test.ts +7 -27
- package/src/proxmox/credential-errors.ts +33 -0
- package/src/proxmox/credentials.test.ts +29 -3
- package/src/proxmox/credentials.ts +4 -134
- package/src/proxmox/distilled-guard.ts +42 -0
- package/src/proxmox/distilled-pve.ts +15 -4
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -4
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/group-wire.ts +63 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +135 -105
- package/src/proxmox/lease-cache.ts +2 -1
- package/src/proxmox/lxc-lifecycle.ts +2 -1
- package/src/proxmox/mint.ts +158 -0
- package/src/proxmox/network-apply.ts +1 -1
- package/src/proxmox/provision-cli-fake.ts +11 -0
- package/src/proxmox/provision-declare.test.ts +6 -1
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +147 -106
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +112 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +132 -129
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `credentials.ts`'s one typed mint failure (S21) — split out to keep that file under the
|
|
3
|
+
* 250-line cap once this class grew a real header (2026-09-24, the cries-wolf fix).
|
|
4
|
+
*/
|
|
5
|
+
import * as Data from 'effect/Data';
|
|
6
|
+
import type { PveRole } from './credentials.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OpenBao answered 403 to `GET /v1/<mount>/creds/<role>`: this identity's AppRole has no grant
|
|
10
|
+
* for `role` on `mount` — measured 2026-09-24 as "the agent lane cannot mint `provision`" (the
|
|
11
|
+
* lane's policy grants `read` but not `provision`). A TYPED tag, not the plain `Error` every
|
|
12
|
+
* other mint failure still is (404 no such role/mount, 503 sealed, a network failure), because
|
|
13
|
+
* this ONE case means "refused, not evidence of anything about the object" — a family's
|
|
14
|
+
* `readOrUnreadable` (unreadable-read.ts) `catchTag`s exactly this, never any other mint
|
|
15
|
+
* failure, which stays a hard error precisely because it is NOT a scoped refusal.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ WHY NOT EVERY NON-2xx: A 503 (sealed) or a 404 (misconfigured mount/role) is the estate
|
|
18
|
+
* BROKEN, not this identity being narrowly scoped — folding those into "unreadable, report
|
|
19
|
+
* noop" would hide a real outage behind a quiet, misleadingly reassuring plan. Only 403 is
|
|
20
|
+
* "this identity, this role, denied" and safe to read as "cannot see, not evidence of absence".
|
|
21
|
+
*/
|
|
22
|
+
export class PveCredentialDenied extends Data.TaggedError('PveCredentialDenied')<{
|
|
23
|
+
readonly mount: string;
|
|
24
|
+
readonly role: PveRole;
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
}> {
|
|
27
|
+
override get message(): string {
|
|
28
|
+
return (
|
|
29
|
+
`OpenBao GET /v1/${this.mount}/creds/${this.role} -> 403: ${this.detail}. This identity's ` +
|
|
30
|
+
'AppRole has no grant for this role -- a refused read, not evidence the object is gone.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -12,7 +12,8 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { describe, it } from 'node:test';
|
|
13
13
|
import * as Effect from 'effect/Effect';
|
|
14
14
|
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
|
-
import {
|
|
15
|
+
import type { BaoEnvironment, PveTarget } from './credentials.ts';
|
|
16
|
+
import { mint } from './mint.ts';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* ⚠️ THIS PACKAGE'S tsconfig CARRIES NO BUN TYPES, and its package.json is out of bounds for this
|
|
@@ -100,8 +101,8 @@ describe('mint', () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
103
|
-
it('fails
|
|
104
|
-
for (const status of [
|
|
104
|
+
it('fails 404 and 503 with OpenBao errors, never the token', async () => {
|
|
105
|
+
for (const status of [404, 503]) {
|
|
105
106
|
await withFake(
|
|
106
107
|
{ json: { errors: [`status ${String(status)}`] }, status },
|
|
107
108
|
async (address) => {
|
|
@@ -117,6 +118,31 @@ describe('mint', () => {
|
|
|
117
118
|
}
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
// ★ 403 IS THE ONE STATUS WITH ITS OWN TAG (credential-errors.ts) — a family's
|
|
122
|
+
// `readOrUnreadable` (unreadable-read.ts) `catchTag`s exactly this, never 404/503 above.
|
|
123
|
+
it('fails 403 as the typed PveCredentialDenied, never the token', async () => {
|
|
124
|
+
await withFake({ json: { errors: ['status 403'] }, status: 403 }, async (address) => {
|
|
125
|
+
const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
|
|
126
|
+
assert.ok(error instanceof Error);
|
|
127
|
+
assert.equal((error as { _tag?: string })._tag, 'PveCredentialDenied');
|
|
128
|
+
assert.match(error.message, /-> 403: status 403\. This identity's AppRole has no grant/);
|
|
129
|
+
assert.ok(!error.message.includes(TOKEN));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ⛔ FOUND ON ADVERSARIAL REVIEW 2026-09-24: status alone is not enough. A WAF or a proxy in
|
|
134
|
+
// front of OpenBao can answer 403 without OpenBao ever seeing the request — folding THAT into
|
|
135
|
+
// `PveCredentialDenied` would hide a real outage behind the same "noop, nothing to worry
|
|
136
|
+
// about" reading the cries-wolf fix exists to stop, only for a different cause.
|
|
137
|
+
it('a 403 with no OpenBao error body stays the untyped refusal, not PveCredentialDenied', async () => {
|
|
138
|
+
await withFake({ json: { message: 'Forbidden' }, status: 403 }, async (address) => {
|
|
139
|
+
const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
|
|
140
|
+
assert.ok(error instanceof Error);
|
|
141
|
+
assert.equal((error as { _tag?: string })._tag, undefined);
|
|
142
|
+
assert.match(error.message, /-> 403: not an OpenBao error body/);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
120
146
|
it('refuses a 200 with no token_id, without quoting the secret', async () => {
|
|
121
147
|
await withFake({ json: { data: { secret: SECRET } }, status: 200 }, async (address) => {
|
|
122
148
|
const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
|
|
@@ -28,13 +28,6 @@
|
|
|
28
28
|
* secret that reached an attribute would outlive its 300s lease by months, in four places.
|
|
29
29
|
* Nothing here returns it to a resource; it is used to build a header and then dropped.
|
|
30
30
|
*/
|
|
31
|
-
import * as Effect from 'effect/Effect';
|
|
32
|
-
import * as Option from 'effect/Option';
|
|
33
|
-
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
34
|
-
import * as Headers from 'effect/unstable/http/Headers';
|
|
35
|
-
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
36
|
-
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
37
|
-
|
|
38
31
|
/**
|
|
39
32
|
* ★ NO SESSION, ON PURPOSE, AND THIS IS WHAT UNBLOCKED `wip/bao-policy`.
|
|
40
33
|
* `CommandExecutor.run` requires a `ScopedPlanStatusSession`, and Alchemy gives one to
|
|
@@ -125,130 +118,7 @@ export type PveCredential = {
|
|
|
125
118
|
/** The environment `mint` resolves OpenBao from — `process.env` unless a test passes its own. */
|
|
126
119
|
export type BaoEnvironment = Readonly<Record<string, string | undefined>>;
|
|
127
120
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Mint one credential for `role`: `GET /v1/<mount>/creds/<role>` on OpenBao's HTTP API.
|
|
134
|
-
*
|
|
135
|
-
* 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
|
|
136
|
-
* client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
|
|
137
|
-
* learned twice what deciding by stderr costs — every failure read as absent, and two commands
|
|
138
|
-
* said "absent" in different words — so this reads STATUS CODES: 2xx is a credential, and 403
|
|
139
|
-
* (denied), 404 (no such role or mount — a mint has no "absent"), 503 (sealed) all fail.
|
|
140
|
-
* ★ THE ENVIRONMENT CONTRACT IS STILL THE CLI's, READ THE CLI's WAY, which keeps the case above
|
|
141
|
-
* true (openbao v2.6.2 api/client.go:34-66, 370, 503-507, 761-762, 776-785): BAO_AGENT_ADDR over
|
|
142
|
-
* BAO_ADDR over https://127.0.0.1:8200; BAO_NAMESPACE as X-Vault-Namespace; BAO_TOKEN as
|
|
143
|
-
* X-Vault-Token; X-Vault-Request always. <estate>/openbao/src/bao-address.ts has the long form.
|
|
144
|
-
* ★ AGENT MODE IS NO BAO_TOKEN AND NO TOKEN HEADER. An Agent or Proxy listener with
|
|
145
|
-
* `api_proxy { use_auto_auth_token = true }` (or the older `cache { … }`) substitutes its
|
|
146
|
-
* auto-auth token exactly when a request carries none — command/agentproxyshared/cache/handler.go
|
|
147
|
-
* :29-38, command/agent.go:517-541, command/agent/config/config.go:288-292, and
|
|
148
|
-
* website/content/docs/agent-and-proxy/agent/apiproxy.mdx:57-62. The CLI's token-file fallback
|
|
149
|
-
* is deliberately not read. ⚠️ So an unset BAO_TOKEN aimed at a SERVER rather than an agent is a
|
|
150
|
-
* 403, which reads like a missing grant rather than a missing variable.
|
|
151
|
-
* ★ UNIX SOCKETS: `unix:///path` is plain HTTP to `localhost` over that socket, as the CLI does it
|
|
152
|
-
* (api/client.go:654-667). Bun's fetch takes a `unix` option; `FetchHttpClient.RequestInit` is
|
|
153
|
-
* Effect's documented way to pass fetch options, read from the calling fiber (effect
|
|
154
|
-
* FetchHttpClient.ts:59-72). Off Bun it REFUSES, rather than reach http://localhost:80.
|
|
155
|
-
*/
|
|
156
|
-
export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = process.env) =>
|
|
157
|
-
Effect.gen(function* () {
|
|
158
|
-
const path = `${target.mount}/creds/${role}`;
|
|
159
|
-
// ⛔ METHOD, PATH AND OpenBao's `errors` ONLY — never a header (the token), never a success
|
|
160
|
-
// body (the credential is IN it, so echoing it would print the secret into a log).
|
|
161
|
-
const refuse = (status: number, detail: string) =>
|
|
162
|
-
new Error(`OpenBao GET /v1/${path} -> ${status === 0 ? 'no response' : status}: ${detail}`);
|
|
163
|
-
const address =
|
|
164
|
-
baoVariable(env, 'AGENT_ADDR') || baoVariable(env, 'ADDR') || 'https://127.0.0.1:8200';
|
|
165
|
-
const socket = address.startsWith('unix://') ? address.slice('unix://'.length) : undefined;
|
|
166
|
-
const base = socket === undefined ? address.replace(/\/+$/, '') : 'http://localhost';
|
|
167
|
-
const headers: Record<string, string> = { 'X-Vault-Request': 'true' };
|
|
168
|
-
const namespace = baoVariable(env, 'NAMESPACE');
|
|
169
|
-
if (namespace !== '') headers['X-Vault-Namespace'] = namespace;
|
|
170
|
-
const token = baoVariable(env, 'TOKEN');
|
|
171
|
-
if (token !== '') headers['X-Vault-Token'] = token;
|
|
172
|
-
if (socket !== undefined && !('Bun' in globalThis)) {
|
|
173
|
-
return yield* Effect.fail(refuse(0, 'a unix:// address needs Bun fetch; this is not Bun'));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const client = yield* HttpClient.HttpClient;
|
|
177
|
-
// ⛔ Effect records request headers on the client span, and its default redaction list does
|
|
178
|
-
// not name x-vault-token (effect Headers.ts:753-762). It is added for this call.
|
|
179
|
-
const redacted = yield* Headers.CurrentRedactedNames;
|
|
180
|
-
const defaults = Option.getOrElse(
|
|
181
|
-
yield* Effect.serviceOption(FetchHttpClient.RequestInit),
|
|
182
|
-
() => ({}),
|
|
183
|
-
);
|
|
184
|
-
const request = HttpClientRequest.get(`${base}/v1/${path}`).pipe(
|
|
185
|
-
HttpClientRequest.setHeaders(headers),
|
|
186
|
-
);
|
|
187
|
-
const exchange = client.execute(request).pipe(
|
|
188
|
-
Effect.flatMap((response) =>
|
|
189
|
-
Effect.map(response.text, (text) => ({ status: response.status, text })),
|
|
190
|
-
),
|
|
191
|
-
// ⚠️ The CLI's default client timeout (api/client.go:324) — a hung agent must not hang a plan.
|
|
192
|
-
Effect.timeout('60 seconds'),
|
|
193
|
-
Effect.provideService(Headers.CurrentRedactedNames, [...redacted, 'x-vault-token']),
|
|
194
|
-
);
|
|
195
|
-
// ⚠️ `| undefined` under exactOptionalPropertyTypes: `socket` is narrowed to a string
|
|
196
|
-
// only on the branch below, so the object literal genuinely carries
|
|
197
|
-
// `unix: string | undefined` and the type must say so. The alternative — building
|
|
198
|
-
// the key conditionally — hides that this request is the unix-socket path.
|
|
199
|
-
const init: RequestInit & { unix?: string | undefined } = { ...defaults, unix: socket };
|
|
200
|
-
const { status, text } = yield* (
|
|
201
|
-
socket === undefined
|
|
202
|
-
? exchange
|
|
203
|
-
: Effect.provideService(exchange, FetchHttpClient.RequestInit, init)
|
|
204
|
-
).pipe(
|
|
205
|
-
// ⛔ `.message` only — the error object holds the request, and the request holds the token.
|
|
206
|
-
Effect.mapError((cause) => refuse(0, cause.message)),
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
let parsed:
|
|
210
|
-
| {
|
|
211
|
-
lease_duration?: unknown;
|
|
212
|
-
data?: { token_id?: unknown; secret?: unknown };
|
|
213
|
-
errors?: unknown;
|
|
214
|
-
}
|
|
215
|
-
| undefined;
|
|
216
|
-
try {
|
|
217
|
-
parsed = JSON.parse(text) as typeof parsed;
|
|
218
|
-
} catch {
|
|
219
|
-
parsed = undefined;
|
|
220
|
-
}
|
|
221
|
-
if (status < 200 || status >= 300) {
|
|
222
|
-
const errors = parsed?.errors;
|
|
223
|
-
const detail = Array.isArray(errors)
|
|
224
|
-
? errors.map(String).join('; ')
|
|
225
|
-
: 'not an OpenBao error body';
|
|
226
|
-
return yield* Effect.fail(refuse(status, detail || '(no errors given)'));
|
|
227
|
-
}
|
|
228
|
-
const tokenId = parsed?.data?.token_id;
|
|
229
|
-
const secret = parsed?.data?.secret;
|
|
230
|
-
if (typeof tokenId !== 'string' || typeof secret !== 'string') {
|
|
231
|
-
// ⛔ REFUSE RATHER THAN RETURN A HALF CREDENTIAL, and again WITHOUT quoting the body.
|
|
232
|
-
return yield* Effect.fail(
|
|
233
|
-
new Error(
|
|
234
|
-
`${target.mount}/creds/${role} returned no token_id/secret. Either this approle lacks ` +
|
|
235
|
-
`the grant, or the mount is not ${target.mount}. Say which mount and role you needed ` +
|
|
236
|
-
`than falling back to a stored credential -- every stored PVE token is read-only.`,
|
|
237
|
-
),
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
const lease = parsed?.lease_duration;
|
|
241
|
-
const leaseSeconds = typeof lease === 'number' ? lease : 0;
|
|
242
|
-
return { leaseSeconds, secret, tokenId } satisfies PveCredential;
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* The header PVE wants. `PVEAPIToken=<tokenid>=<secret>`.
|
|
247
|
-
*
|
|
248
|
-
* ⚠️ THE SEPARATOR IS `=`, NOT `:`. Proxmox rejects the colon form with a 401 that says only
|
|
249
|
-
* "authentication failure", which reads as a bad credential rather than as a malformed header.
|
|
250
|
-
*/
|
|
251
|
-
export const authorization = (credential: PveCredential, target: ApiTarget): string =>
|
|
252
|
-
target.scheme === 'pbs'
|
|
253
|
-
? `PBSAPIToken=${credential.tokenId}:${credential.secret}`
|
|
254
|
-
: `PVEAPIToken=${credential.tokenId}=${credential.secret}`;
|
|
121
|
+
// ★ `mint` and `authorization` live in mint.ts (2026-09-24) — this file is types only now, to
|
|
122
|
+
// stay under the 250-line cap once `PveCredentialDenied`'s catchers needed a real header. See
|
|
123
|
+
// mint.ts's own header. Importers: mint.ts, client.ts, lease-cache.ts, lxc-lifecycle.ts,
|
|
124
|
+
// network-apply.ts, credentials.test.ts.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-constraint guards (constraint-guard.ts) for a distilled-backed family — the seam
|
|
3
|
+
* acl.ts's own local `guardWrite` established (acl-wire.ts), generalized once a second family
|
|
4
|
+
* (Group) needed the SAME create/update split `resource.ts`'s `specGuards` (resource-guard.ts)
|
|
5
|
+
* gives every family still on `client.ts`. `codegen/constraints.ts` scans this package's SOURCE
|
|
6
|
+
* TEXT for the endpoint-key strings a caller passes here, so a family switching to this helper
|
|
7
|
+
* changes nothing about which vendor rules apply — same keys, same generated tables.
|
|
8
|
+
*
|
|
9
|
+
* ★ WHY `asForm` IS THE ONLY TRANSLATION NEEDED. Distilled's generated request types for every
|
|
10
|
+
* PVE form-urlencoded operation are already `Record<string, string | undefined>` — the SAME
|
|
11
|
+
* shape `PveForm`/`guardForm` want, minus the `?`. There is no JSON vs form-encoding gap to
|
|
12
|
+
* bridge here the way client.ts's `pve()` had to.
|
|
13
|
+
*/
|
|
14
|
+
import type * as Effect from 'effect/Effect';
|
|
15
|
+
import { guardForm } from './constraint-guard.ts';
|
|
16
|
+
import type { EndpointKey } from './constraints.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drops non-string fields — distilled's request type carries `?`, `guardForm` wants a plain
|
|
20
|
+
* form. ⚠️ `body: object`, NOT `Record<string, string | undefined>`: distilled's generated
|
|
21
|
+
* request types are `interface`s with no index signature, so TypeScript refuses that stricter
|
|
22
|
+
* parameter type for every one of them ("index signature ... is missing") even though every
|
|
23
|
+
* field they declare fits it. `object` accepts any of them; the cast below is where the shape
|
|
24
|
+
* is actually asserted, once, rather than at every call site.
|
|
25
|
+
*/
|
|
26
|
+
export const asForm = (body: object): Record<string, string> =>
|
|
27
|
+
Object.fromEntries(
|
|
28
|
+
Object.entries(body as Record<string, unknown>).filter(
|
|
29
|
+
(e): e is [string, string] => typeof e[1] === 'string',
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `presence`: whether a CREATE is really about to happen (required fields are enforced) or the
|
|
35
|
+
* form is only being value-checked in passing — see resource-guard.ts's own header on why this
|
|
36
|
+
* is not "this is the create form" but "a create is really about to happen".
|
|
37
|
+
*/
|
|
38
|
+
export const guardWrite = (
|
|
39
|
+
endpoint: EndpointKey,
|
|
40
|
+
body: object,
|
|
41
|
+
presence: boolean,
|
|
42
|
+
): Effect.Effect<void> => guardForm(endpoint, asForm(body), presence);
|
|
@@ -58,6 +58,7 @@ import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
|
58
58
|
import { credentials as distilledPveCredentials } from '@distilled.cloud/proxmox/Credentials';
|
|
59
59
|
import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
|
|
60
60
|
import * as ProxmoxRetry from '@distilled.cloud/proxmox/Retry';
|
|
61
|
+
import type { PveCredentialDenied } from './credential-errors.ts';
|
|
61
62
|
import type { PveCredential, PveRole, PveTarget } from './credentials.ts';
|
|
62
63
|
import { leased } from './lease-cache.ts';
|
|
63
64
|
import {
|
|
@@ -131,16 +132,26 @@ export const runPveWith = <A, E>(
|
|
|
131
132
|
* resource file calls.
|
|
132
133
|
*
|
|
133
134
|
* ⚠️ `| Error` IN THE RETURN TYPE IS `leased()`'s OWN FAILURE, NOT A NEW ONE THIS FILE ADDS.
|
|
134
|
-
* `
|
|
135
|
-
*
|
|
136
|
-
*
|
|
135
|
+
* `mint.ts`'s `mint` fails with a plain `Error` for most refusals (sealed, no such role) and
|
|
136
|
+
* was never typed more narrowly there; `pve()` carried the same untyped possibility before
|
|
137
|
+
* this file existed, just without a signature that said so.
|
|
138
|
+
*
|
|
139
|
+
* ★ `| PveCredentialDenied` IS SPELLED OUT HERE, NOT LEFT TO COLLAPSE INTO `Error`. It IS an
|
|
140
|
+
* `Error` subtype (Data.TaggedError), so leaving it out of this union would still typecheck —
|
|
141
|
+
* but `Effect.catchTag('PveCredentialDenied', …)` downstream (unreadable-read.ts) needs the
|
|
142
|
+
* tag to be STATICALLY visible in the error channel to compile at all, not merely true at
|
|
143
|
+
* runtime. Naming it here is what lets a migrated family's `readOrUnreadable` catch it.
|
|
137
144
|
*/
|
|
138
145
|
export const runPve = <A, E>(
|
|
139
146
|
target: PveTarget,
|
|
140
147
|
role: PveRole,
|
|
141
148
|
isWrite: boolean,
|
|
142
149
|
op: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
143
|
-
): Effect.Effect<
|
|
150
|
+
): Effect.Effect<
|
|
151
|
+
A,
|
|
152
|
+
E | Cause.TimeoutError | PveClusterExhausted | PveCredentialDenied | Error,
|
|
153
|
+
HttpClient.HttpClient
|
|
154
|
+
> =>
|
|
144
155
|
Effect.gen(function* () {
|
|
145
156
|
const credential = yield* leased(target, role);
|
|
146
157
|
return yield* runPveWith(target, credential, isWrite, op);
|
|
@@ -52,4 +52,4 @@ export const PROXMOX_CONSTRAINTS: Readonly<Record<string, EndpointConstraints>>
|
|
|
52
52
|
* ★ A DIGEST OF THE DATA, NOT OF THE FILE TEXT, so reformatting is not a stale generation while
|
|
53
53
|
* changing a 128 to a 129 by hand is. `tests/schema-manifest.test.ts` recomputes it.
|
|
54
54
|
*/
|
|
55
|
-
export const PROXMOX_CONSTRAINTS_DIGEST = '
|
|
55
|
+
export const PROXMOX_CONSTRAINTS_DIGEST = '56eba80750ddb1d2';
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -39,9 +39,6 @@ export const PVE_ACCESS_CONSTRAINTS: Readonly<Record<string, EndpointConstraints
|
|
|
39
39
|
"password": {"maxLength":64,"minLength":8,"type":"string"},
|
|
40
40
|
"userid": {"format":"pve-userid","maxLength":64,"required":true,"type":"string"},
|
|
41
41
|
},
|
|
42
|
-
"pve:POST /access/users/{userid}/token/{tokenid}": {
|
|
43
|
-
"expire": {"default":"same as user","minimum":0,"type":"integer"},
|
|
44
|
-
},
|
|
45
42
|
"pve:PUT /access/acl": {
|
|
46
43
|
"groups": {"format":"pve-groupid-list","type":"string"},
|
|
47
44
|
"path": {"required":true,"type":"string"},
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Group`'s wire shape: PVE's create/update forms and how a live item becomes
|
|
3
|
+
* attributes. Split out of group.ts (2026-09-24) to keep that file under the 250-line cap —
|
|
4
|
+
* the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
|
|
5
|
+
*/
|
|
6
|
+
import type * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import type { GroupAttributes, GroupProps } from './group.ts';
|
|
8
|
+
import { csv, text } from './values.ts';
|
|
9
|
+
|
|
10
|
+
export const GROUP_CREATE = 'pve:POST /access/groups';
|
|
11
|
+
export const GROUP_UPDATE = 'pve:PUT /access/groups/{groupid}';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The comment PVE will actually END UP HOLDING for a given declaration.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ PVE CANNOT STORE THE COMMENT `'0'`, AND A PROVIDER THAT DOES NOT MODEL THAT DIFFS FOREVER.
|
|
17
|
+
* `create_group`/`write_user_config`/the config parser all test the comment for TRUTH, and in
|
|
18
|
+
* Perl the one-character string `'0'` is false. So a declared `comment: '0'` is accepted by the
|
|
19
|
+
* API, dropped on the way to `user.cfg`, and read back as absent. Normalising BOTH sides is how
|
|
20
|
+
* `values.ts` already treats `''` as the absence of a boolean rather than as `false`.
|
|
21
|
+
*/
|
|
22
|
+
export const storedComment = (comment: string | undefined) => {
|
|
23
|
+
const value = comment ?? '';
|
|
24
|
+
return value === '0' ? '' : value;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The item read's `members` array, sorted into something two plans can agree on — group.ts's header. */
|
|
28
|
+
const memberList = (live: readonly string[]): string[] => {
|
|
29
|
+
const joined = csv(live.map((member) => text(member)));
|
|
30
|
+
return joined === '' ? [] : joined.split(',');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const createForm = (props: GroupProps): access.CreateAccessGroupRequest => ({
|
|
34
|
+
comment: storedComment(props.comment),
|
|
35
|
+
groupid: props.groupid,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** ⚠️ ALWAYS SENT, EVEN EMPTY — that is how a comment is CLEARED (see group.ts's own header). */
|
|
39
|
+
export const updateForm = (props: GroupProps): access.PutAccessGroupRequest => ({
|
|
40
|
+
comment: storedComment(props.comment),
|
|
41
|
+
groupid: props.groupid,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const attributesOf = (
|
|
45
|
+
live: access.GetAccessGroupResponse,
|
|
46
|
+
props: GroupProps,
|
|
47
|
+
): GroupAttributes => ({
|
|
48
|
+
/**
|
|
49
|
+
* ⚠️ ABSENT IS EMPTY, NOT MISSING — `read_group` sets `comment` only `if defined(…)`. distilled
|
|
50
|
+
* types it `comment?: string`, so `text()`'s `''` fallback meets the same normalisation.
|
|
51
|
+
*/
|
|
52
|
+
comment: text(live.comment),
|
|
53
|
+
/** ⚠️ FROM PROPS — the item read never echoes the id back. */
|
|
54
|
+
groupid: props.groupid,
|
|
55
|
+
members: memberList(live.members),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ `members` IS NOT COMPARED, AND NOTHING ELSE IS LEFT TO COMPARE. `comment` is the only field
|
|
60
|
+
* a PUT here accepts, so it is the only field whose drift this provider can repair.
|
|
61
|
+
*/
|
|
62
|
+
export const matches = (attributes: GroupAttributes, props: GroupProps) =>
|
|
63
|
+
attributes.comment === storedComment(props.comment);
|