@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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every Discord application-command object — global and guild-scoped alike.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THE ALTERNATIVE IS COPYING THE SAME READ/RECONCILE/DELETE PER RESOURCE. `application-command.ts`
|
|
5
|
+
* and `guild-application-command.ts` differ only in which distilled operations they call and
|
|
6
|
+
* whether `guild_id` is part of identity — so the read/diff/reconcile/delete flow lives here
|
|
7
|
+
* once, mirroring `../netbox/resource.ts`'s `netboxOperations`/`netboxHandlers` split.
|
|
8
|
+
*
|
|
9
|
+
* ★ UNLIKE NETBOX, THIS FOLLOWS UPSTREAM'S `Snippet.ts` READ/RECONCILE SHAPE EXACTLY (S7–S9), NOT
|
|
10
|
+
* netbox's "adopt silently" gap (H1). Discord commands carry no ownership marker of any kind —
|
|
11
|
+
* no tags, no metadata field a bot could stamp — so this is precisely the "marker-less API" S8
|
|
12
|
+
* describes, and `Snippet.ts@v2.0.0-beta.79#read` is the cited reference: an existing match is
|
|
13
|
+
* `Unowned(attrs)` on a cold read (no persisted `output`), and plain attributes on a warm read
|
|
14
|
+
* (persisted `output` says we already made or adopted this exact command). `adopt(true)` (H5),
|
|
15
|
+
* piped onto each resource by the constructors in the two resource files, is what turns that
|
|
16
|
+
* `Unowned` into a silent one-time takeover instead of an `OwnedBySomeoneElse` refusal.
|
|
17
|
+
*
|
|
18
|
+
* ★ RECONCILE IS ONE UPSERT CALL, LIKE `Snippet.ts`'s `putSnippet`. Discord's own docs for both
|
|
19
|
+
* Create Global/Guild Application Command say a command with the same name (and type) as an
|
|
20
|
+
* existing one OVERWRITES it — the create endpoint is a true upsert, not a create-only call that
|
|
21
|
+
* 409s on a name collision. ⚠️ REASONED NOT MEASURED (no token available to this task; see
|
|
22
|
+
* `docs/discord.md`): this is Discord's documented behaviour, unchanged across API v10, but it
|
|
23
|
+
* has not been exercised against the real API by this change. One call therefore converges
|
|
24
|
+
* greenfield create, routine update AND adoption alike — there is no separate PATCH path, and
|
|
25
|
+
* S10's "zero write calls when there is no drift" is still honoured because `reconcile` skips
|
|
26
|
+
* the call entirely when `spec.matches` already holds.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ `list` ANSWERS EMPTY, ON PURPOSE, EVEN THOUGH THE DISTILLED LIST OPERATIONS EXIST. A bot's
|
|
29
|
+
* guild command set is exactly what `hf-discord-halibut.service` bulk-overwrites at every
|
|
30
|
+
* restart (see `docs/discord.md#halibut`); a `nuke` that enumerated and swept every live command
|
|
31
|
+
* would fight that ownership, not just this resource's own declarations. Adoption stays
|
|
32
|
+
* explicit, the same reasoning netbox's `list` gives for a shared instance predating the kit.
|
|
33
|
+
*/
|
|
34
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
35
|
+
import { isResolved } from 'alchemy/Diff';
|
|
36
|
+
import type { Input } from 'alchemy/Input';
|
|
37
|
+
import type { DiscordOpContext } from '@distilled.cloud/discord/Protocol';
|
|
38
|
+
import * as Effect from 'effect/Effect';
|
|
39
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
40
|
+
import { CredentialsFromEnv } from './credentials.ts';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What every stack must still provide after `discordHandlers` bakes in `CredentialsFromEnv`
|
|
44
|
+
* (below): the transport. `DiscordOpContext` is `Credentials | HttpClient.HttpClient`, so once
|
|
45
|
+
* `Credentials` is supplied, `HttpClient.HttpClient` is what remains — mirroring
|
|
46
|
+
* `../netbox/resource.ts#NetboxRequirements`.
|
|
47
|
+
*/
|
|
48
|
+
export type DiscordRequirements = HttpClient.HttpClient;
|
|
49
|
+
|
|
50
|
+
/** Every Discord command resource's identity key, regardless of scope. */
|
|
51
|
+
export interface DiscordCommandIdentity {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One Discord command object's distilled calls. `Live` is whatever the SDK decodes
|
|
57
|
+
* (`ApplicationCommandResponse`); `E` is left to each resource file to declare, so a call site's
|
|
58
|
+
* precise error union (in practice always `DiscordOpError` — see `protocol.ts` — because every
|
|
59
|
+
* generated Discord operation shares one error channel) flows straight through.
|
|
60
|
+
*/
|
|
61
|
+
export type DiscordSpec<
|
|
62
|
+
Props extends DiscordCommandIdentity,
|
|
63
|
+
Live,
|
|
64
|
+
Attributes extends object,
|
|
65
|
+
E,
|
|
66
|
+
> = {
|
|
67
|
+
/** Locate the live command by its declared name within this spec's scope (app, or app+guild). */
|
|
68
|
+
readonly fetchByName: (props: Props) => Effect.Effect<Live | undefined, E, DiscordOpContext>;
|
|
69
|
+
readonly attributes: (live: Live) => Attributes;
|
|
70
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
71
|
+
readonly upsertBody: (props: Props) => Record<string, unknown>;
|
|
72
|
+
/** POST create-or-overwrite — see the upsert note above. */
|
|
73
|
+
readonly upsert: (
|
|
74
|
+
props: Props,
|
|
75
|
+
body: Record<string, unknown>,
|
|
76
|
+
) => Effect.Effect<Live, E, DiscordOpContext>;
|
|
77
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, DiscordOpContext>;
|
|
78
|
+
/** For error messages and test descriptions. */
|
|
79
|
+
readonly describe: (props: Props) => string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const discordOperations = <
|
|
83
|
+
Props extends DiscordCommandIdentity,
|
|
84
|
+
Live,
|
|
85
|
+
Attributes extends object,
|
|
86
|
+
E,
|
|
87
|
+
>(
|
|
88
|
+
spec: DiscordSpec<Props, Live, Attributes, E>,
|
|
89
|
+
) => ({
|
|
90
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const live = yield* spec.fetchByName(olds);
|
|
93
|
+
if (live === undefined) return undefined;
|
|
94
|
+
const attrs = spec.attributes(live);
|
|
95
|
+
// Warm read: a prior apply already created or adopted this exact command — ours.
|
|
96
|
+
if (output !== undefined) return attrs;
|
|
97
|
+
// Cold read (fresh store, or state lost): no ownership marker exists on this vendor,
|
|
98
|
+
// so any match is `Unowned` until `adopt(true)` (or `--adopt`) says otherwise (S7, S8).
|
|
99
|
+
return Unowned(attrs);
|
|
100
|
+
}),
|
|
101
|
+
|
|
102
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
105
|
+
const live = yield* spec.fetchByName(news);
|
|
106
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
107
|
+
return spec.matches(spec.attributes(live), news)
|
|
108
|
+
? ({ action: 'noop' } as const)
|
|
109
|
+
: ({ action: 'update' } as const);
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
reconcile: (news: Props) =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const before = yield* spec.fetchByName(news);
|
|
115
|
+
if (before !== undefined && spec.matches(spec.attributes(before), news)) {
|
|
116
|
+
// Already exactly what is declared — S10: no write.
|
|
117
|
+
return spec.attributes(before);
|
|
118
|
+
}
|
|
119
|
+
const body = spec.upsertBody(news);
|
|
120
|
+
const after = yield* spec.upsert(news, body);
|
|
121
|
+
return spec.attributes(after);
|
|
122
|
+
}),
|
|
123
|
+
|
|
124
|
+
// ⚠️ GAP, NOT SOLVED: a TOCTOU race between the fetch above and the delete call below could
|
|
125
|
+
// see Discord answer 404 on a command that vanished between the two — S11 asks that race to
|
|
126
|
+
// be treated as success, `catchTag('NotFound', …)`. The SDK's declared error type for every
|
|
127
|
+
// operation does not include `NotFound` (see `docs/discord.md#sdk-gaps` — measured, not
|
|
128
|
+
// assumed), so there is no typed tag to catch here without an unsafe cast. Left unhandled.
|
|
129
|
+
destroy: (olds: Props) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const live = yield* spec.fetchByName(olds);
|
|
132
|
+
if (live === undefined) return;
|
|
133
|
+
yield* spec.destroy(olds, live);
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The four provider handlers for a spec'd Discord command, wired once.
|
|
139
|
+
*
|
|
140
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE. `CredentialsFromEnv` resolves `DISCORD_BOT_TOKEN` /
|
|
141
|
+
* `DISCORD_TOKEN` on the calling fiber per request (credentials.ts), so this closure keeps that
|
|
142
|
+
* laziness — nothing is captured at module load.
|
|
143
|
+
*/
|
|
144
|
+
export const discordHandlers = <
|
|
145
|
+
Props extends DiscordCommandIdentity,
|
|
146
|
+
Live,
|
|
147
|
+
Attributes extends object,
|
|
148
|
+
E,
|
|
149
|
+
>(
|
|
150
|
+
spec: DiscordSpec<Props, Live, Attributes, E>,
|
|
151
|
+
) => {
|
|
152
|
+
const ops = discordOperations(spec);
|
|
153
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, DiscordOpContext>) =>
|
|
154
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
155
|
+
return {
|
|
156
|
+
list: () => Effect.succeed([]),
|
|
157
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
158
|
+
withCredentials(ops.read({ olds, output })),
|
|
159
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
160
|
+
withCredentials(ops.diff(news, output)),
|
|
161
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
162
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
163
|
+
};
|
|
164
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord's 429 handling — proving the SDK's OWN default retry policy (`@distilled.cloud/core`'s
|
|
3
|
+
* `makeDefault`, threaded through every operation via `retry: Retry.Retry` — see `resource.ts`'s
|
|
4
|
+
* header note and `docs/discord.md#rate-limits`), not a house re-implementation. This file exists
|
|
5
|
+
* because "respect Discord's rate limits with a bounded retry" is a requirement this task must
|
|
6
|
+
* satisfy, and the honest way to satisfy it here is to prove the SDK already does — nothing in
|
|
7
|
+
* `resource.ts` installs a `Discord.Retry.policy` of its own, so if this test ever failed to
|
|
8
|
+
* retry, or failed to STOP retrying, there would be nothing in this package to fix; the fix would
|
|
9
|
+
* be in `@distilled.cloud/discord`.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ A TEST THAT CANNOT FAIL PROVES NOTHING. Both cases below assert on the fake's own call count
|
|
12
|
+
* (`fake.seen.length`), not just on the eventual Effect outcome — a policy that retried once,
|
|
13
|
+
* or one that retried forever, would each still "eventually succeed" or "eventually fail" on
|
|
14
|
+
* SOME outcome; the call count is what makes "bounded" a real, checkable claim rather than an
|
|
15
|
+
* assumption the test's own passing would not have caught.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import { spec } from './guild-application-command.ts';
|
|
20
|
+
import type { GuildApplicationCommandProps } from './guild-application-command.ts';
|
|
21
|
+
import { fakeDiscord, fakeDiscordLayer, fakeRateLimited } from './fake-discord.ts';
|
|
22
|
+
|
|
23
|
+
const APP = '111111111111111111';
|
|
24
|
+
const GUILD = '222222222222222222';
|
|
25
|
+
const CMD_PATH = `/applications/${APP}/guilds/${GUILD}/commands`;
|
|
26
|
+
const PROPS: GuildApplicationCommandProps = { applicationId: APP, guildId: GUILD, name: 'ping' };
|
|
27
|
+
|
|
28
|
+
const liveCommand = () => ({
|
|
29
|
+
application_id: APP,
|
|
30
|
+
default_member_permissions: null,
|
|
31
|
+
description: '',
|
|
32
|
+
guild_id: GUILD,
|
|
33
|
+
id: '999',
|
|
34
|
+
name: 'ping',
|
|
35
|
+
nsfw: false,
|
|
36
|
+
type: 1,
|
|
37
|
+
version: '1',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('the SDK default Retry policy on a 429', () => {
|
|
41
|
+
test('two 429s (Retry-After: 0) then a 200 — retried, and it converges', async () => {
|
|
42
|
+
const fake = fakeDiscord((method, url, _body, callNumber) => {
|
|
43
|
+
if (method !== 'GET' || url.pathname !== CMD_PATH) return new Response(null, { status: 500 });
|
|
44
|
+
return callNumber <= 2 ? fakeRateLimited(0) : Response.json([liveCommand()]);
|
|
45
|
+
});
|
|
46
|
+
const live = await Effect.runPromise(
|
|
47
|
+
spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
|
|
48
|
+
);
|
|
49
|
+
expect(live?.id).toBe('999');
|
|
50
|
+
expect(fake.seen).toHaveLength(3);
|
|
51
|
+
}, 15_000);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ★ THE BOUND ITSELF. `@distilled.cloud/core`'s `makeDefault` caps this at `Schedule.recurs(8)`
|
|
55
|
+
* — 8 retries, 9 attempts total — regardless of how long the server keeps answering 429. A
|
|
56
|
+
* server that never recovers must still let the caller find out, in bounded time, rather than
|
|
57
|
+
* parking the fiber forever; that is the literal meaning of "bounded retry".
|
|
58
|
+
*/
|
|
59
|
+
test('429 forever is a bounded number of attempts, then a typed failure — never an infinite loop', async () => {
|
|
60
|
+
const fake = fakeDiscord((method, url) =>
|
|
61
|
+
method === 'GET' && url.pathname === CMD_PATH
|
|
62
|
+
? fakeRateLimited(0)
|
|
63
|
+
: new Response(null, { status: 500 }),
|
|
64
|
+
);
|
|
65
|
+
const failure = await Effect.runPromise(
|
|
66
|
+
Effect.flip(spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch)))),
|
|
67
|
+
);
|
|
68
|
+
expect(failure._tag).toBe('TooManyRequests');
|
|
69
|
+
// 1 initial attempt + at most 8 retries. Strictly greater than 1 proves a retry happened at
|
|
70
|
+
// all; at most 9 proves it stopped rather than continuing past the documented bound.
|
|
71
|
+
expect(fake.seen.length).toBeGreaterThan(1);
|
|
72
|
+
expect(fake.seen.length).toBeLessThanOrEqual(9);
|
|
73
|
+
}, 15_000);
|
|
74
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Acl`'s wire shape: PVE's PUT form, identity, and how a live row becomes attributes.
|
|
3
|
+
* Split out of acl.ts (2026-09-24) to keep that file under the 250-line cap once the cries-wolf
|
|
4
|
+
* fix needed real space — the api-token.ts/api-token-form.ts seam, same reasoning: this file
|
|
5
|
+
* answers "what does PVE store", acl.ts answers "when has it changed" and drives the provider.
|
|
6
|
+
*/
|
|
7
|
+
import type * as access from '@distilled.cloud/proxmox/access';
|
|
8
|
+
import { guardForm } from './constraint-guard.ts';
|
|
9
|
+
import type { AclAttributes, AclProps } from './acl.ts';
|
|
10
|
+
|
|
11
|
+
/** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
|
|
12
|
+
export const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
|
|
13
|
+
|
|
14
|
+
/** The write names the subject with a plural key that differs per kind; the read answers singular. */
|
|
15
|
+
const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
|
|
16
|
+
|
|
17
|
+
/** The vendor endpoint every write and every constraint check runs against. */
|
|
18
|
+
const ENDPOINT = 'pve:PUT /access/acl';
|
|
19
|
+
|
|
20
|
+
/** The tuple PVE keys a grant by, as PUT's fields. */
|
|
21
|
+
export const tuple = (props: AclProps): access.PutAccessAclRequest => ({
|
|
22
|
+
path: normalize(props.path),
|
|
23
|
+
roles: props.roleid,
|
|
24
|
+
[SUBJECT_FIELD[props.type]]: props.ugid,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Create and update are one call: the tuple plus the single mutable field. */
|
|
28
|
+
export const bind = (props: AclProps): access.PutAccessAclRequest => ({
|
|
29
|
+
...tuple(props),
|
|
30
|
+
propagate: props.propagate === false ? '0' : '1',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** `guardForm` wants a plain string-valued form; distilled's request type carries `?` instead. */
|
|
34
|
+
const asForm = (body: access.PutAccessAclRequest): Record<string, string> =>
|
|
35
|
+
Object.fromEntries(Object.entries(body).filter((e): e is [string, string] => e[1] !== undefined));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ⚠️ ALWAYS PRESENCE-CHECKED. `bind`'s output IS the create form (there is no separate,
|
|
39
|
+
* narrower update form for this family — see acl.ts's header), so the vendor's
|
|
40
|
+
* required-parameter check is never wrong to run; the two separate create/update passes the
|
|
41
|
+
* generic factory ran elsewhere collapse to one call here without losing coverage.
|
|
42
|
+
*/
|
|
43
|
+
export const guardWrite = (props: AclProps) => guardForm(ENDPOINT, asForm(bind(props)), true);
|
|
44
|
+
|
|
45
|
+
/** What a change of this string means: not an edit, a different grant. */
|
|
46
|
+
export const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
|
|
47
|
+
[normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
|
|
48
|
+
|
|
49
|
+
const find = (rows: readonly access.ListAccessAclResponseBodyItem[], props: AclProps) =>
|
|
50
|
+
rows.find(
|
|
51
|
+
(row) =>
|
|
52
|
+
row.path === normalize(props.path) &&
|
|
53
|
+
row.type === props.type &&
|
|
54
|
+
row.ugid === props.ugid &&
|
|
55
|
+
row.roleid === props.roleid,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ PVE answers `propagate` as 1/0 rather than true/false, and OMITS it when it carries the API
|
|
60
|
+
* default, which is ON. distilled's generated schema types it `unknown` for the same reason.
|
|
61
|
+
*/
|
|
62
|
+
const propagates = (row: access.ListAccessAclResponseBodyItem) => {
|
|
63
|
+
const value = row.propagate;
|
|
64
|
+
return value === undefined || value === 1 || value === true || value === '1';
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const attributesOf = (
|
|
68
|
+
rows: readonly access.ListAccessAclResponseBodyItem[],
|
|
69
|
+
props: AclProps,
|
|
70
|
+
): AclAttributes => {
|
|
71
|
+
const row = find(rows, props);
|
|
72
|
+
return {
|
|
73
|
+
bound: row !== undefined,
|
|
74
|
+
path: normalize(props.path),
|
|
75
|
+
propagate: row !== undefined && propagates(row),
|
|
76
|
+
roleid: props.roleid,
|
|
77
|
+
type: props.type,
|
|
78
|
+
ugid: props.ugid,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const matches = (attributes: AclAttributes, props: AclProps) =>
|
|
83
|
+
attributes.bound && attributes.propagate === (props.propagate !== false);
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -47,9 +47,15 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
47
47
|
import * as Provider from 'alchemy/Provider';
|
|
48
48
|
import * as access from '@distilled.cloud/proxmox/access';
|
|
49
49
|
import * as Effect from 'effect/Effect';
|
|
50
|
-
import {
|
|
50
|
+
import { attributesOf, bind, guardWrite, identity, matches, tuple } from './acl-wire.ts';
|
|
51
51
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
52
52
|
import { runPve } from './distilled-pve.ts';
|
|
53
|
+
import {
|
|
54
|
+
UNREADABLE,
|
|
55
|
+
type Unreadable,
|
|
56
|
+
readOrUnreadable,
|
|
57
|
+
unreadableWarning,
|
|
58
|
+
} from './unreadable-read.ts';
|
|
53
59
|
|
|
54
60
|
/** PVE's three kinds of subject. The read answers this word; the write wants its plural. */
|
|
55
61
|
export type AclSubjectType = 'user' | 'group' | 'token';
|
|
@@ -91,91 +97,23 @@ export interface ProxmoxAcl extends Resource<
|
|
|
91
97
|
|
|
92
98
|
export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
|
|
93
99
|
|
|
94
|
-
/** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
|
|
95
|
-
const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
|
|
96
|
-
|
|
97
|
-
/** The write names the subject with a plural key that differs per kind; the read answers singular. */
|
|
98
|
-
const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
|
|
99
|
-
|
|
100
|
-
/** The vendor endpoint every write and every constraint check runs against. */
|
|
101
|
-
const ENDPOINT = 'pve:PUT /access/acl';
|
|
102
|
-
|
|
103
|
-
/** The tuple PVE keys a grant by, as PUT's fields. */
|
|
104
|
-
const tuple = (props: AclProps): access.PutAccessAclRequest => ({
|
|
105
|
-
path: normalize(props.path),
|
|
106
|
-
roles: props.roleid,
|
|
107
|
-
[SUBJECT_FIELD[props.type]]: props.ugid,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
/** Create and update are one call: the tuple plus the single mutable field. */
|
|
111
|
-
const bind = (props: AclProps): access.PutAccessAclRequest => ({
|
|
112
|
-
...tuple(props),
|
|
113
|
-
propagate: props.propagate === false ? '0' : '1',
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
/** `guardForm` wants a plain string-valued form; distilled's request type carries `?` instead. */
|
|
117
|
-
const asForm = (body: access.PutAccessAclRequest): Record<string, string> =>
|
|
118
|
-
Object.fromEntries(Object.entries(body).filter((e): e is [string, string] => e[1] !== undefined));
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* ⚠️ ALWAYS PRESENCE-CHECKED. `bind`'s output IS the create form (there is no separate,
|
|
122
|
-
* narrower update form for this family — see the header), so the vendor's required-parameter
|
|
123
|
-
* check is never wrong to run; the two separate create/update passes the generic factory ran
|
|
124
|
-
* elsewhere collapse to one call here without losing coverage.
|
|
125
|
-
*/
|
|
126
|
-
const guardWrite = (props: AclProps) => guardForm(ENDPOINT, asForm(bind(props)), true);
|
|
127
|
-
|
|
128
|
-
/** What a change of this string means: not an edit, a different grant. */
|
|
129
|
-
const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
|
|
130
|
-
[normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
|
|
131
|
-
|
|
132
|
-
const find = (rows: readonly access.ListAccessAclResponseBodyItem[], props: AclProps) =>
|
|
133
|
-
rows.find(
|
|
134
|
-
(row) =>
|
|
135
|
-
row.path === normalize(props.path) &&
|
|
136
|
-
row.type === props.type &&
|
|
137
|
-
row.ugid === props.ugid &&
|
|
138
|
-
row.roleid === props.roleid,
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* ⚠️ PVE answers `propagate` as 1/0 rather than true/false, and OMITS it when it carries the API
|
|
143
|
-
* default, which is ON. distilled's generated schema types it `unknown` for the same reason.
|
|
144
|
-
*/
|
|
145
|
-
const propagates = (row: access.ListAccessAclResponseBodyItem) => {
|
|
146
|
-
const value = row.propagate;
|
|
147
|
-
return value === undefined || value === 1 || value === true || value === '1';
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const attributesOf = (
|
|
151
|
-
rows: readonly access.ListAccessAclResponseBodyItem[],
|
|
152
|
-
props: AclProps,
|
|
153
|
-
): AclAttributes => {
|
|
154
|
-
const row = find(rows, props);
|
|
155
|
-
return {
|
|
156
|
-
bound: row !== undefined,
|
|
157
|
-
path: normalize(props.path),
|
|
158
|
-
propagate: row !== undefined && propagates(row),
|
|
159
|
-
roleid: props.roleid,
|
|
160
|
-
type: props.type,
|
|
161
|
-
ugid: props.ugid,
|
|
162
|
-
};
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const matches = (attributes: AclAttributes, props: AclProps) =>
|
|
166
|
-
attributes.bound && attributes.propagate === (props.propagate !== false);
|
|
167
|
-
|
|
168
100
|
/**
|
|
169
101
|
* ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
|
|
170
|
-
* ⛔ on filtering. `
|
|
171
|
-
*
|
|
102
|
+
* ⛔ on filtering. `orElseSucceed` still folds every OTHER failure into `undefined`.
|
|
103
|
+
* ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
|
|
104
|
+
* "cries wolf" bug — comes back `UNREADABLE` instead of the same `undefined` a genuine
|
|
105
|
+
* absence produces, so `diff` below can tell them apart.
|
|
172
106
|
*/
|
|
173
107
|
const readAttributes = (props: AclProps) =>
|
|
174
|
-
runPve(props.target, 'provision', false, access.listAccessAcl({})).pipe(
|
|
175
|
-
Effect.map((rows) => attributesOf(rows, props)),
|
|
108
|
+
readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
|
|
109
|
+
Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
|
|
176
110
|
Effect.orElseSucceed(() => undefined),
|
|
177
111
|
);
|
|
178
112
|
|
|
113
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
114
|
+
const dropUnreadable = (live: AclAttributes | Unreadable | undefined) =>
|
|
115
|
+
live === UNREADABLE ? undefined : live;
|
|
116
|
+
|
|
179
117
|
export const ProxmoxAclProvider = () =>
|
|
180
118
|
Provider.effect(
|
|
181
119
|
ProxmoxAcl,
|
|
@@ -184,7 +122,7 @@ export const ProxmoxAclProvider = () =>
|
|
|
184
122
|
/** ⛔ EMPTY LIKE EVERY OTHER PVE RESOURCE. Adopting every grant a human ever clicked is not adoption. */
|
|
185
123
|
list: () => Effect.succeed([]),
|
|
186
124
|
read: Effect.fn(function* ({ olds }) {
|
|
187
|
-
return yield* readAttributes(olds);
|
|
125
|
+
return dropUnreadable(yield* readAttributes(olds));
|
|
188
126
|
}),
|
|
189
127
|
/**
|
|
190
128
|
* ⛔ AN IDENTITY CHANGE IS A REPLACE. Editing `roleid` (or the subject, or the path) would
|
|
@@ -201,6 +139,12 @@ export const ProxmoxAclProvider = () =>
|
|
|
201
139
|
yield* guardWrite(news);
|
|
202
140
|
if (output === undefined) return undefined;
|
|
203
141
|
const live = yield* readAttributes(news);
|
|
142
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and
|
|
143
|
+
// force `update` on a grant that was plainly there — see unreadable-read.ts.
|
|
144
|
+
if (live === UNREADABLE) {
|
|
145
|
+
yield* unreadableWarning('Proxmox.Acl', identity(news));
|
|
146
|
+
return { action: 'noop' } as const;
|
|
147
|
+
}
|
|
204
148
|
return live !== undefined && matches(live, news)
|
|
205
149
|
? ({ action: 'noop' } as const)
|
|
206
150
|
: ({ action: 'update' } as const);
|
|
@@ -212,12 +156,15 @@ export const ProxmoxAclProvider = () =>
|
|
|
212
156
|
* the gap.
|
|
213
157
|
*/
|
|
214
158
|
reconcile: Effect.fn(function* ({ news }) {
|
|
215
|
-
|
|
159
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
160
|
+
// write below, so `UNREADABLE` here is a near-impossible race, not the routine case
|
|
161
|
+
// `diff` handles — treating it as "not yet bound" costs at most one redundant PUT.
|
|
162
|
+
const before = dropUnreadable(yield* readAttributes(news));
|
|
216
163
|
yield* guardWrite(news);
|
|
217
164
|
if (before === undefined || !matches(before, news)) {
|
|
218
165
|
yield* runPve(news.target, 'provision', true, access.putAccessAcl(bind(news)));
|
|
219
166
|
}
|
|
220
|
-
const after = yield* readAttributes(news);
|
|
167
|
+
const after = dropUnreadable(yield* readAttributes(news));
|
|
221
168
|
if (after === undefined || !after.bound) {
|
|
222
169
|
return yield* Effect.die(
|
|
223
170
|
new Error(
|
|
@@ -16,9 +16,11 @@ import { engineOver } from '../verify/fake-engine.ts';
|
|
|
16
16
|
import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
17
17
|
import type { PbsTarget } from './credentials.ts';
|
|
18
18
|
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
19
20
|
import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
20
21
|
import { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
|
|
21
22
|
import { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
|
|
23
|
+
import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
22
24
|
|
|
23
25
|
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
24
26
|
|
|
@@ -54,6 +56,22 @@ const cases: readonly Case[] = [
|
|
|
54
56
|
name: 'Proxmox.Acl (wraps the factory reconcile)',
|
|
55
57
|
provider: ProxmoxAclProvider as never,
|
|
56
58
|
},
|
|
59
|
+
{
|
|
60
|
+
declare: () =>
|
|
61
|
+
ProxmoxGroup('mint', { comment: 'fence', groupid: 'hf-mint', target: FAKE_TARGET }) as never,
|
|
62
|
+
live: { 'access/groups/hf-mint': { comment: 'fence', members: [] } },
|
|
63
|
+
name: 'Proxmox.Group (its own reconcile, since the distilled migration)',
|
|
64
|
+
provider: ProxmoxGroupProvider as never,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// ⚠️ KEY IS PERCENT-ENCODED — user.ts's own ⚠️: distilled's `{userid}` substitution encodes
|
|
68
|
+
// the `@`, and `clusterWith` below does no decoding of `call.path`.
|
|
69
|
+
declare: () =>
|
|
70
|
+
ProxmoxUser('iac', { comment: 'x', target: FAKE_TARGET, userid: 'iac@pve' }) as never,
|
|
71
|
+
live: { 'access/users/iac%40pve': { comment: 'x', enable: 1, expire: 0 } },
|
|
72
|
+
name: 'Proxmox.User (its own reconcile, since the distilled migration)',
|
|
73
|
+
provider: ProxmoxUserProvider as never,
|
|
74
|
+
},
|
|
57
75
|
{
|
|
58
76
|
declare: () =>
|
|
59
77
|
PbsDatastore('store1', {
|
|
@@ -17,10 +17,6 @@ import { engineOver } from '../verify/fake-engine.ts';
|
|
|
17
17
|
import { type ApiTokenProps, ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
|
|
18
18
|
import { shape } from './api-token-form.ts';
|
|
19
19
|
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
20
|
-
import type {
|
|
21
|
-
AccessUsersUseridTokenTokenidPostParams,
|
|
22
|
-
AccessUsersUseridTokenTokenidPutParams,
|
|
23
|
-
} from './generated/pve.ts';
|
|
24
20
|
|
|
25
21
|
type Live = { comment?: string; expire: number | string; privsep: number };
|
|
26
22
|
|
|
@@ -32,12 +28,19 @@ const tokens = new Map<string, Live>([
|
|
|
32
28
|
['access/users/svc@pve/token/lease2', { comment: 'lease', expire: '1789327175', privsep: 0 }],
|
|
33
29
|
]);
|
|
34
30
|
|
|
31
|
+
/**
|
|
32
|
+
* ⚠️ `decodeURIComponent` — user.ts's own ⚠️, and it applies here too: distilled's `{userid}`/
|
|
33
|
+
* `{tokenid}` label substitution percent-encodes `iac@pve` to `iac%40pve`, where `client.ts`'s
|
|
34
|
+
* plain string concatenation never did. `tokens`'s keys stay literal (`@`); only the lookup
|
|
35
|
+
* decodes, so this map does not have to be rewritten for a wire-level detail.
|
|
36
|
+
*/
|
|
35
37
|
const cluster = () =>
|
|
36
38
|
fakePve((call: PveCall) => {
|
|
37
|
-
|
|
39
|
+
const key = decodeURIComponent(call.path);
|
|
40
|
+
if (call.method === 'GET') return tokens.get(key);
|
|
38
41
|
if (call.method === 'PUT') {
|
|
39
|
-
const live = tokens.get(
|
|
40
|
-
if (live !== undefined) tokens.set(
|
|
42
|
+
const live = tokens.get(key);
|
|
43
|
+
if (live !== undefined) tokens.set(key, { ...live, ...call.form });
|
|
41
44
|
return call.form;
|
|
42
45
|
}
|
|
43
46
|
return undefined;
|
|
@@ -125,14 +128,27 @@ describe('adopting a metadata-only ApiToken declaration', () => {
|
|
|
125
128
|
expect(await engine.deploy(flipped())).toEqual({ 'iac-pve-apply': 'adopted' });
|
|
126
129
|
});
|
|
127
130
|
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
128
|
-
|
|
131
|
+
// ⚠️ PERCENT-ENCODED — see the ⚠️ on `cluster` above.
|
|
132
|
+
expect(fake.writes()).toEqual(['PUT access/users/iac%40pve/token/apply']);
|
|
133
|
+
// ⛔ `userid`/`tokenid` DO NOT LEAK INTO THE WIRE FORM, even though `shape()` (below) must
|
|
134
|
+
// include them for distilled's `putAccessUserToken` call — they are `T.Label()` fields,
|
|
135
|
+
// pulled into the URL by the protocol layer and never sent in the body. MEASURED here.
|
|
129
136
|
expect(put?.pairs.map(([name]) => name)).toEqual(['comment', 'expire', 'privsep']);
|
|
130
137
|
});
|
|
131
138
|
|
|
132
|
-
|
|
139
|
+
// ★ THE OLD PROOF ("typechecks against the generated PVE params, no cast") is now `shape()`'s
|
|
140
|
+
// own return type (`access.PutAccessUserTokenRequest`), checked at compile time by every
|
|
141
|
+
// caller. This pins the one thing that is not: `userid`/`tokenid` ARE in the object `shape`
|
|
142
|
+
// builds (distilled needs them to fill the URL), even though the wire test above proves
|
|
143
|
+
// they never reach the actual PUT body.
|
|
144
|
+
test('shape() carries userid/tokenid for distilled, alongside the three mutable fields', () => {
|
|
133
145
|
const props = propsFor('iac@pve', 'apply', { comment: 'automation' });
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
expect(shape(props)).toEqual({
|
|
147
|
+
comment: 'automation',
|
|
148
|
+
expire: '0',
|
|
149
|
+
privsep: '0',
|
|
150
|
+
tokenid: 'apply',
|
|
151
|
+
userid: 'iac@pve',
|
|
152
|
+
});
|
|
137
153
|
});
|
|
138
154
|
});
|