@homeflare/distilled-proxmox-backup 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +19 -17
  2. package/dist/errors.d.ts +6 -5
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js.map +1 -1
  5. package/dist/missing-resource.test.d.ts +2 -0
  6. package/dist/missing-resource.test.d.ts.map +1 -0
  7. package/dist/missing-resource.test.js +62 -0
  8. package/dist/missing-resource.test.js.map +1 -0
  9. package/dist/protocol-boundaries.test.d.ts +2 -0
  10. package/dist/protocol-boundaries.test.d.ts.map +1 -0
  11. package/dist/protocol-boundaries.test.js +50 -0
  12. package/dist/protocol-boundaries.test.js.map +1 -0
  13. package/dist/protocol-codecs.d.ts +23 -0
  14. package/dist/protocol-codecs.d.ts.map +1 -0
  15. package/dist/protocol-codecs.js +49 -0
  16. package/dist/protocol-codecs.js.map +1 -0
  17. package/dist/protocol-codecs.test.d.ts +2 -0
  18. package/dist/protocol-codecs.test.d.ts.map +1 -0
  19. package/dist/protocol-codecs.test.js +58 -0
  20. package/dist/protocol-codecs.test.js.map +1 -0
  21. package/dist/protocol-raw-root.test.d.ts +2 -0
  22. package/dist/protocol-raw-root.test.d.ts.map +1 -0
  23. package/dist/protocol-raw-root.test.js +48 -0
  24. package/dist/protocol-raw-root.test.js.map +1 -0
  25. package/dist/protocol.d.ts +3 -2
  26. package/dist/protocol.d.ts.map +1 -1
  27. package/dist/protocol.js +7 -2
  28. package/dist/protocol.js.map +1 -1
  29. package/dist/protocol.test.js +2 -2
  30. package/dist/protocol.test.js.map +1 -1
  31. package/dist/services/config.d.ts +44 -4
  32. package/dist/services/config.d.ts.map +1 -1
  33. package/dist/services/config.js +29 -4
  34. package/dist/services/config.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/errors.ts +7 -11
  37. package/src/missing-resource.test.ts +98 -0
  38. package/src/protocol-boundaries.test.ts +60 -0
  39. package/src/protocol-codecs.test.ts +112 -0
  40. package/src/protocol-codecs.ts +75 -0
  41. package/src/protocol-raw-root.test.ts +84 -0
  42. package/src/protocol.test.ts +2 -2
  43. package/src/protocol.ts +67 -60
  44. package/src/services/config.ts +45 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/distilled-proxmox-backup",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Interim npm home for @distilled.cloud/proxmox-backup — see docs/distilled-interim.md. Not published upstream yet; the kit aliases @distilled.cloud/proxmox-backup onto this package until it is.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/distilled-proxmox-backup#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -73,9 +73,9 @@
73
73
  "access": "public"
74
74
  },
75
75
  "dependencies": {
76
- "@distilled.cloud/core": "1.0.0-rc.12"
76
+ "@distilled.cloud/core": "1.0.0-rc.13"
77
77
  },
78
78
  "peerDependencies": {
79
- "effect": "4.0.0-rc.115"
79
+ "effect": "4.0.1"
80
80
  }
81
81
  }
package/src/errors.ts CHANGED
@@ -37,12 +37,8 @@ export {
37
37
  API_ERRORS,
38
38
  } from "@distilled.cloud/core/errors";
39
39
  // `export { X } from "mod"` (above) is a RE-EXPORT ONLY — it creates no local
40
- // binding for `X`, so `BadRequest` needs its own `import type` to be usable
41
- // in this file's own `DefaultErrors` union below.
42
- import type {
43
- BadRequest,
44
- DefaultErrors as CoreDefaultErrors,
45
- } from "@distilled.cloud/core/errors";
40
+ // binding for `X`, so the API error tuple needs a type import for its union below.
41
+ import type { API_ERRORS } from "@distilled.cloud/core/errors";
46
42
 
47
43
  import * as Schema from "effect/Schema";
48
44
  import * as Category from "@distilled.cloud/core/category";
@@ -115,12 +111,12 @@ export class ProxmoxBackupTaskFailed extends Schema.TaggedError<ProxmoxBackupTas
115
111
  export type ClientErrors = UnknownProxmoxBackupError | ProxmoxBackupParseError;
116
112
 
117
113
  /**
118
- * Default Proxmox Backup Server operation errors: the shared HTTP status
119
- * errors from core, PBS's global parameter-verification failure, plus the
120
- * client-level fallback/decode errors.
114
+ * Every status class the protocol's HTTP_STATUS_MAP may return, plus PBS failures.
115
+ * Core's DefaultErrors is only its infrastructure subset; using that here hid mapped
116
+ * NotFound/Forbidden/Conflict from the typed operation union, so a legitimate catchTag
117
+ * could not compile even though that exact error was returned at runtime.
121
118
  */
122
119
  export type DefaultErrors =
123
- | CoreDefaultErrors
124
- | BadRequest
120
+ | InstanceType<(typeof API_ERRORS)[number]>
125
121
  | ParameterVerificationFailed
126
122
  | ClientErrors;
@@ -0,0 +1,98 @@
1
+ /** Read-only PBS wire measurements, 2026-09-24: missing config items use plain-text 400. */
2
+ import { describe, expect, test } from "bun:test";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Layer from "effect/Layer";
5
+ import * as HttpClient from "effect/http/HttpClient";
6
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
7
+ import { credentials } from "./credentials.ts";
8
+ import type { ProxmoxBackupOpContext } from "./protocol.ts";
9
+ import * as Retry from "./retry.ts";
10
+ import * as config from "./services/config.ts";
11
+
12
+ const testCredentials = credentials({
13
+ tokenId: "backup@pbs!test",
14
+ secret: "test-secret",
15
+ baseUrl: "https://pbs.test:8007",
16
+ });
17
+ const failure = (
18
+ op: Effect.Effect<unknown, unknown, ProxmoxBackupOpContext>,
19
+ status: number,
20
+ body: string,
21
+ ) =>
22
+ Effect.runPromise(
23
+ op.pipe(
24
+ Retry.none,
25
+ Effect.provide(
26
+ Layer.mergeAll(
27
+ testCredentials,
28
+ Layer.succeed(
29
+ HttpClient.HttpClient,
30
+ HttpClient.make((request) =>
31
+ Effect.succeed(
32
+ HttpClientResponse.fromWeb(
33
+ request,
34
+ new Response(body, { status }),
35
+ ),
36
+ ),
37
+ ),
38
+ ),
39
+ ),
40
+ ),
41
+ Effect.flip,
42
+ ),
43
+ );
44
+ const absent = "hf-absent-sdk-0924";
45
+ const cases = [
46
+ {
47
+ op: config.getConfigDatastore({ name: absent }),
48
+ kind: "datastore",
49
+ tag: "DatastoreNotFound",
50
+ },
51
+ {
52
+ op: config.getConfigPrune({ id: absent }),
53
+ kind: "prune",
54
+ tag: "PruneJobNotFound",
55
+ },
56
+ {
57
+ op: config.getConfigSync({ id: absent }),
58
+ kind: "sync",
59
+ tag: "SyncJobNotFound",
60
+ },
61
+ {
62
+ op: config.getConfigVerify({ id: absent }),
63
+ kind: "verification",
64
+ tag: "VerifyJobNotFound",
65
+ },
66
+ ] as const;
67
+
68
+ describe("PBS resource absence", () => {
69
+ for (const { op, kind, tag } of cases) {
70
+ test(`${kind}: exact measured 400 is a typed missing resource`, async () => {
71
+ expect(
72
+ await failure(op, 400, `no such ${kind} '${absent}'`),
73
+ ).toMatchObject({ _tag: tag });
74
+ });
75
+ test(`${kind}: a different status, kind or extended error is not absence`, async () => {
76
+ for (const [status, body] of [
77
+ [403, `no such ${kind} '${absent}'`],
78
+ [500, `no such ${kind} '${absent}'`],
79
+ [400, "no such unrelated 'id'"],
80
+ [400, `no such ${kind} '${absent}': configuration unreadable`],
81
+ [400, "parameter verification failed"],
82
+ ] as const) {
83
+ expect(await failure(op, status, body)).not.toMatchObject({
84
+ _tag: tag,
85
+ });
86
+ }
87
+ });
88
+ }
89
+ test("missing text is scoped to the named GET operation", async () => {
90
+ expect(
91
+ await failure(
92
+ config.listConfigDatastore({}),
93
+ 400,
94
+ `no such datastore '${absent}'`,
95
+ ),
96
+ ).not.toMatchObject({ _tag: "DatastoreNotFound" });
97
+ });
98
+ });
@@ -0,0 +1,60 @@
1
+ /** The PBS adapter preserves core's decoded-value and nested-form contracts. */
2
+ import { expect, test } from "bun:test";
3
+ import * as API from "@distilled.cloud/core/api";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Layer from "effect/Layer";
6
+ import * as Schema from "effect/Schema";
7
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
8
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
9
+ import { withPbsCodecs } from "./protocol-codecs.ts";
10
+
11
+ const request = HttpClientRequest.post("https://pbs.test/config").pipe(
12
+ HttpClientRequest.bodyUrlParams([
13
+ ["target[0]", "mail"],
14
+ ["target[1]", "hook"],
15
+ ["nested[targets][0]", "keep"],
16
+ ["scalar", "a,b"],
17
+ ]),
18
+ );
19
+ const base = Layer.succeed(
20
+ API.Protocol,
21
+ API.Protocol.of({
22
+ encode: () => Effect.succeed(request),
23
+ decode: () => Effect.succeed(42),
24
+ }),
25
+ );
26
+ const protocol = withPbsCodecs(base);
27
+
28
+ test("only top-level indexed form lists change", async () => {
29
+ const encoded = await Effect.runPromise(
30
+ Effect.gen(function* () {
31
+ const adapter = yield* API.Protocol;
32
+ return yield* adapter.encode({
33
+ input: {},
34
+ inputAst: Schema.Unknown.ast,
35
+ config: {},
36
+ });
37
+ }).pipe(Effect.provide(protocol)),
38
+ );
39
+ expect(encoded.body._tag).toBe("Uint8Array");
40
+ if (encoded.body._tag !== "Uint8Array") return;
41
+ const form = new URLSearchParams(new TextDecoder().decode(encoded.body.body));
42
+ expect(form.getAll("target")).toEqual(["mail", "hook"]);
43
+ expect(form.get("nested[targets][0]")).toBe("keep");
44
+ expect(form.get("scalar")).toBe("a,b");
45
+ });
46
+
47
+ test("core-decoded output is validated on the Type side without decoding twice", async () => {
48
+ const result = await Effect.runPromise(
49
+ Effect.gen(function* () {
50
+ const adapter = yield* API.Protocol;
51
+ return yield* adapter.decode({
52
+ response: HttpClientResponse.fromWeb(request, new Response("42")),
53
+ outputAst: Schema.NumberFromString.ast,
54
+ config: { output: Schema.NumberFromString },
55
+ errors: [],
56
+ });
57
+ }).pipe(Effect.provide(protocol)),
58
+ );
59
+ expect(result).toBe(42);
60
+ });
@@ -0,0 +1,112 @@
1
+ import { expect, test } from "bun:test";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import type { HttpClientRequest } from "effect/http/HttpClientRequest";
6
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
7
+ import { credentials } from "./credentials.ts";
8
+ import * as Retry from "./retry.ts";
9
+ import {
10
+ getConfigNotificationMatcher,
11
+ putConfigNotificationMatcher,
12
+ } from "./services/config.ts";
13
+
14
+ const credential = credentials({
15
+ baseUrl: "https://pbs.test:8007",
16
+ tokenId: "test@pbs!fake",
17
+ secret: "fake-not-real",
18
+ });
19
+
20
+ const fakePbs = (body: unknown, see?: (request: HttpClientRequest) => void) =>
21
+ Layer.succeed(
22
+ HttpClient.HttpClient,
23
+ HttpClient.make((request) =>
24
+ Effect.sync(() => {
25
+ see?.(request);
26
+ return HttpClientResponse.fromWeb(
27
+ request,
28
+ Response.json({ data: body }),
29
+ );
30
+ }),
31
+ ),
32
+ );
33
+
34
+ test("PBS arrays use repeated keys and Unit data:null remains a successful write", async () => {
35
+ let pairs: [string, string][] = [];
36
+ const client = fakePbs(null, (request) => {
37
+ expect(request.body._tag).toBe("Uint8Array");
38
+ if (request.body._tag === "Uint8Array") {
39
+ pairs = [
40
+ ...new URLSearchParams(new TextDecoder().decode(request.body.body)),
41
+ ];
42
+ }
43
+ });
44
+ await Effect.runPromise(
45
+ putConfigNotificationMatcher({
46
+ name: "pager",
47
+ target: ["mail-to-root", "hook"],
48
+ delete: ["match-field", "match-calendar"],
49
+ }).pipe(Retry.none, Effect.provide(Layer.mergeAll(client, credential))),
50
+ );
51
+ expect(pairs.filter(([key]) => key === "target")).toEqual([
52
+ ["target", "mail-to-root"],
53
+ ["target", "hook"],
54
+ ]);
55
+ expect(pairs.filter(([key]) => key === "delete")).toEqual([
56
+ ["delete", "match-field"],
57
+ ["delete", "match-calendar"],
58
+ ]);
59
+ });
60
+
61
+ for (const body of [null, { comment: "secret-like-text-must-not-appear" }]) {
62
+ test("incomplete resource output fails without exposing response contents", async () => {
63
+ const error = await Effect.runPromise(
64
+ getConfigNotificationMatcher({ name: "pager" }).pipe(
65
+ Retry.none,
66
+ Effect.flip,
67
+ Effect.provide(Layer.mergeAll(fakePbs(body), credential)),
68
+ ),
69
+ );
70
+ expect(error).toMatchObject({ _tag: "ProxmoxBackupParseError" });
71
+ expect(JSON.stringify(error)).not.toContain(
72
+ "secret-like-text-must-not-appear",
73
+ );
74
+ });
75
+ }
76
+
77
+ test("valid resource output preserves fields omitted from the vendor schema", async () => {
78
+ const result = await Effect.runPromise(
79
+ getConfigNotificationMatcher({ name: "pager" }).pipe(
80
+ Retry.none,
81
+ Effect.provide(
82
+ Layer.mergeAll(
83
+ fakePbs({ name: "pager", future_field: "keep-me" }),
84
+ credential,
85
+ ),
86
+ ),
87
+ ),
88
+ );
89
+ expect(result).toMatchObject({ name: "pager", future_field: "keep-me" });
90
+ });
91
+
92
+ test("mapped NotFound is in the operation's typed union, including a plain-text response", async () => {
93
+ const client = Layer.succeed(
94
+ HttpClient.HttpClient,
95
+ HttpClient.make((request) =>
96
+ Effect.succeed(
97
+ HttpClientResponse.fromWeb(
98
+ request,
99
+ new Response("matcher 'pager' not found", { status: 404 }),
100
+ ),
101
+ ),
102
+ ),
103
+ );
104
+ const result = await Effect.runPromise(
105
+ getConfigNotificationMatcher({ name: "pager" }).pipe(
106
+ Retry.none,
107
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
108
+ Effect.provide(Layer.mergeAll(client, credential)),
109
+ ),
110
+ );
111
+ expect(result).toBeUndefined();
112
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * PBS 4.2.6-1's form arrays and successful response validation.
3
+ *
4
+ * The vendor's array parameters are repeated keys (`target=a&target=b`), not the Stripe
5
+ * bracket-index encoding used by core's generic REST builder. Measured against the PBS
6
+ * notification provider's existing protocol fixtures, 2026-09-24: indexed headers/secrets
7
+ * disappear from its read-back, and matchers perpetually drift. Preserve core path/auth/key
8
+ * mapping and adapt only its top-level indexed form fields at this vendor boundary.
9
+ */
10
+ import * as API from "@distilled.cloud/core/api";
11
+ import * as Effect from "effect/Effect";
12
+ import * as Layer from "effect/Layer";
13
+ import * as Schema from "effect/Schema";
14
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
15
+ import { ProxmoxBackupParseError } from "./errors.ts";
16
+
17
+ const repeatedFormKeys = (request: HttpClientRequest.HttpClientRequest) => {
18
+ if (
19
+ request.body._tag !== "Uint8Array" ||
20
+ request.body.contentType !== "application/x-www-form-urlencoded"
21
+ )
22
+ return request;
23
+ const fields = new URLSearchParams(
24
+ new TextDecoder().decode(request.body.body),
25
+ );
26
+ const repeated: [string, string][] = [];
27
+ for (const [key, value] of fields) {
28
+ repeated.push([key.replace(/^([^\[\]]+)\[\d+\]$/, "$1"), value]);
29
+ }
30
+ return HttpClientRequest.bodyUrlParams(request, repeated);
31
+ };
32
+
33
+ /**
34
+ * Core REST decode maps wire keys but does not validate required fields. In particular,
35
+ * PBS data:null unwrapped to {} used to look like a real resource to a caller. Validate
36
+ * with the operation's generated schema: Unit operations still accept {}, while a GET
37
+ * missing its required name/id fails. Preserve the mapped payload after validation: vendor
38
+ * fields absent from the published schema must not silently disappear. Core has already
39
+ * decoded the payload, so validate the schema's Type side instead of re-decoding transforms.
40
+ * Keep server data out of the parse error: notification responses can contain header
41
+ * credentials, and a schema diagnostic can reproduce them.
42
+ */
43
+ export const withPbsCodecs = (
44
+ base: Layer.Layer<API.Protocol>,
45
+ ): Layer.Layer<API.Protocol> =>
46
+ Layer.effect(
47
+ API.Protocol,
48
+ Effect.gen(function* () {
49
+ const protocol = yield* API.Protocol;
50
+ return API.Protocol.of({
51
+ encode: (args) =>
52
+ protocol.encode(args).pipe(Effect.map(repeatedFormKeys)),
53
+ decode: (args) =>
54
+ protocol.decode(args).pipe(
55
+ Effect.flatMap((body) =>
56
+ args.config.output === undefined
57
+ ? Effect.succeed(body)
58
+ : (Schema.decodeUnknownEffect(
59
+ Schema.toType(args.config.output),
60
+ )(body).pipe(
61
+ Effect.as(body),
62
+ Effect.mapError(
63
+ () =>
64
+ new ProxmoxBackupParseError({
65
+ body: undefined,
66
+ cause:
67
+ "Response does not match the operation's declared output schema",
68
+ }),
69
+ ),
70
+ ) as Effect.Effect<unknown>),
71
+ ),
72
+ ),
73
+ });
74
+ }),
75
+ ).pipe(Layer.provide(base));
@@ -0,0 +1,84 @@
1
+ import { expect, test } from "bun:test";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
6
+ import { credentials } from "./credentials.ts";
7
+ import type { ProxmoxBackupOpContext } from "./protocol.ts";
8
+ import * as Retry from "./retry.ts";
9
+ import {
10
+ deleteConfigDatastore,
11
+ getConfigAcmeTos,
12
+ listConfigDatastore,
13
+ } from "./services/config.ts";
14
+
15
+ const run = <A, E>(
16
+ operation: Effect.Effect<A, E, ProxmoxBackupOpContext>,
17
+ data: unknown,
18
+ ) =>
19
+ Effect.runPromise(
20
+ operation.pipe(
21
+ Retry.none,
22
+ Effect.provide(
23
+ Layer.mergeAll(
24
+ credentials({
25
+ baseUrl: "https://pbs.test:8007",
26
+ tokenId: "test@pbs!fake",
27
+ secret: "fake-not-real",
28
+ }),
29
+ Layer.succeed(
30
+ HttpClient.HttpClient,
31
+ HttpClient.make((request) =>
32
+ Effect.succeed(
33
+ HttpClientResponse.fromWeb(request, Response.json({ data })),
34
+ ),
35
+ ),
36
+ ),
37
+ ),
38
+ ),
39
+ ),
40
+ );
41
+
42
+ test("raw-root string responses retain task IDs and ACME terms URLs", async () => {
43
+ const upid =
44
+ "UPID:pbs:00000001:00000002:00000003:delete-datastore:test:root@pam:";
45
+ expect(await run(deleteConfigDatastore({ name: "test" }), upid)).toBe(upid);
46
+ const url = "https://acme.test/terms";
47
+ expect(await run(getConfigAcmeTos({}), url)).toBe(url);
48
+ });
49
+
50
+ test("raw-root lists retain empty lists, map wire keys, and preserve extra fields", async () => {
51
+ expect(await run(listConfigDatastore({}), [])).toEqual([]);
52
+ const expected = [
53
+ { name: "test", path: "/backup/test", keep_last: 2, future_field: "keep" },
54
+ ];
55
+ expect(
56
+ await run(listConfigDatastore({}), [
57
+ {
58
+ name: "test",
59
+ path: "/backup/test",
60
+ "keep-last": 2,
61
+ future_field: "keep",
62
+ },
63
+ ]),
64
+ ).toEqual(expected);
65
+ });
66
+
67
+ // These operations are not Unit responses: null must not become a valid empty object.
68
+ for (const data of [null, {}]) {
69
+ test(`raw-root operations reject ${JSON.stringify(data)} instead of accepting missing output`, async () => {
70
+ const operations: Effect.Effect<
71
+ unknown,
72
+ unknown,
73
+ ProxmoxBackupOpContext
74
+ >[] = [
75
+ deleteConfigDatastore({ name: "test" }),
76
+ getConfigAcmeTos({}),
77
+ listConfigDatastore({}),
78
+ ];
79
+ for (const operation of operations) {
80
+ const error = await run(Effect.flip(operation), data);
81
+ expect(error).toMatchObject({ _tag: "ProxmoxBackupParseError" });
82
+ }
83
+ });
84
+ }
@@ -16,8 +16,8 @@
16
16
  import { describe, expect, test } from "bun:test";
17
17
  import * as Effect from "effect/Effect";
18
18
  import * as Layer from "effect/Layer";
19
- import * as HttpClient from "effect/unstable/http/HttpClient";
20
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
19
+ import * as HttpClient from "effect/http/HttpClient";
20
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
21
21
  import { credentials } from "./credentials.ts";
22
22
  import {
23
23
  BadRequest,
package/src/protocol.ts CHANGED
@@ -76,8 +76,8 @@
76
76
  import * as Effect from "effect/Effect";
77
77
  import type * as Layer from "effect/Layer";
78
78
  import * as Redacted from "effect/Redacted";
79
- import type * as HttpClient from "effect/unstable/http/HttpClient";
80
- import type * as HttpClientError from "effect/unstable/http/HttpClientError";
79
+ import type * as HttpClient from "effect/http/HttpClient";
80
+ import type * as HttpClientError from "effect/http/HttpClientError";
81
81
  import type * as API from "@distilled.cloud/core/api";
82
82
  import {
83
83
  BadRequest,
@@ -89,8 +89,10 @@ import {
89
89
  type RestErrorEnvelope,
90
90
  } from "@distilled.cloud/core/protocol-rest";
91
91
  import { Credentials, type Config } from "./credentials.ts";
92
+ import { withPbsCodecs } from "./protocol-codecs.ts";
92
93
  import {
93
94
  ParameterVerificationFailed,
95
+ ProxmoxBackupParseError,
94
96
  UnknownProxmoxBackupError,
95
97
  type DefaultErrors,
96
98
  } from "./errors.ts";
@@ -132,65 +134,70 @@ const PROXMOX_BACKUP_STATUS_MAP: Record<number, new (args: any) => unknown> = {
132
134
  };
133
135
  delete (PROXMOX_BACKUP_STATUS_MAP as Record<number, unknown>)[400];
134
136
 
135
- export const ProxmoxBackupProtocol: Layer.Layer<API.Protocol> =
136
- makeRestProtocol<Config>({
137
- credentials: Effect.gen(function* () {
138
- const resolve = yield* Credentials;
139
- return yield* resolve;
140
- }),
141
- baseUrl: (creds) => creds.apiBaseUrl,
142
- headers: (creds) => ({
143
- // ⛔ COLON, NOT `=` — see the module header's Authentication section.
144
- Authorization: `PBSAPIToken=${creds.tokenId}:${Redacted.value(creds.secret)}`,
145
- Accept: "application/json",
146
- }),
147
- errorEnvelope,
148
- statusMap: PROXMOX_BACKUP_STATUS_MAP,
149
- // Unwrap PBS's `{"data": …}` envelope BEFORE the schema-driven decode —
150
- // see the module header. `data` is `null`/absent for a Unit-output
151
- // operation; `?? {}` is core's own convention for "no body".
152
- transformResponse: (body) => {
137
+ const baseProtocol: Layer.Layer<API.Protocol> = makeRestProtocol<Config>({
138
+ credentials: Effect.gen(function* () {
139
+ const resolve = yield* Credentials;
140
+ return yield* resolve;
141
+ }),
142
+ baseUrl: (creds) => creds.apiBaseUrl,
143
+ headers: (creds) => ({
144
+ // ⛔ COLON, NOT `=` — see the module header's Authentication section.
145
+ Authorization: `PBSAPIToken=${creds.tokenId}:${Redacted.value(creds.secret)}`,
146
+ Accept: "application/json",
147
+ }),
148
+ errorEnvelope,
149
+ statusMap: PROXMOX_BACKUP_STATUS_MAP,
150
+ // Unwrap PBS's `{"data": …}` envelope BEFORE the schema-driven decode —
151
+ // see the module header. `data` is `null`/absent for a Unit-output
152
+ // operation; `?? {}` is core's own convention for "no body".
153
+ transformResponse: (body) => {
154
+ if (
155
+ body !== null &&
156
+ typeof body === "object" &&
157
+ "data" in (body as Record<string, unknown>)
158
+ ) {
159
+ return (body as Record<string, unknown>).data ?? {};
160
+ }
161
+ return body;
162
+ },
163
+ // Reached for a 400 (see PROXMOX_BACKUP_STATUS_MAP above) or a status
164
+ // with no core mapping at all. `body` is the FULL parsed failure
165
+ // envelope, so this is where PBS's per-field `errors` object is read.
166
+ //
167
+ // ⛔ A BARE 400 WITHOUT THE `errors` OBJECT MUST NOT FALL INTO
168
+ // `UnknownProxmoxBackupError` — same retry-amplification hazard
169
+ // PVE's identical comment explains (`ServerError` is retried
170
+ // automatically; a malformed request is permanent). `BadRequest`
171
+ // (`Category.withBadRequestError`, not retryable) is the honest
172
+ // answer for "PBS said 400 and gave no further structure".
173
+ // A 2xx body that fails strict output validation (core rc.13).
174
+ parseError: ({ body, cause }) => new ProxmoxBackupParseError({ body, cause }),
175
+ unknownError: ({ status, message, body }) => {
176
+ const b =
177
+ body !== null && typeof body === "object"
178
+ ? (body as Record<string, unknown>)
179
+ : undefined;
180
+ const fieldErrors = b?.errors;
181
+ if (status === 400) {
153
182
  if (
154
- body !== null &&
155
- typeof body === "object" &&
156
- "data" in (body as Record<string, unknown>)
183
+ fieldErrors !== null &&
184
+ typeof fieldErrors === "object" &&
185
+ !Array.isArray(fieldErrors)
157
186
  ) {
158
- return (body as Record<string, unknown>).data ?? {};
159
- }
160
- return body;
161
- },
162
- // Reached for a 400 (see PROXMOX_BACKUP_STATUS_MAP above) or a status
163
- // with no core mapping at all. `body` is the FULL parsed failure
164
- // envelope, so this is where PBS's per-field `errors` object is read.
165
- //
166
- // ⛔ A BARE 400 WITHOUT THE `errors` OBJECT MUST NOT FALL INTO
167
- // `UnknownProxmoxBackupError` — same retry-amplification hazard
168
- // PVE's identical comment explains (`ServerError` is retried
169
- // automatically; a malformed request is permanent). `BadRequest`
170
- // (`Category.withBadRequestError`, not retryable) is the honest
171
- // answer for "PBS said 400 and gave no further structure".
172
- unknownError: ({ status, message, body }) => {
173
- const b =
174
- body !== null && typeof body === "object"
175
- ? (body as Record<string, unknown>)
176
- : undefined;
177
- const fieldErrors = b?.errors;
178
- if (status === 400) {
179
- if (
180
- fieldErrors !== null &&
181
- typeof fieldErrors === "object" &&
182
- !Array.isArray(fieldErrors)
183
- ) {
184
- const errors: Record<string, string> = {};
185
- for (const [k, v] of Object.entries(
186
- fieldErrors as Record<string, unknown>,
187
- )) {
188
- if (typeof v === "string") errors[k] = v;
189
- }
190
- return new ParameterVerificationFailed({ message, errors });
187
+ const errors: Record<string, string> = {};
188
+ for (const [k, v] of Object.entries(
189
+ fieldErrors as Record<string, unknown>,
190
+ )) {
191
+ if (typeof v === "string") errors[k] = v;
191
192
  }
192
- return new BadRequest({ message });
193
+ return new ParameterVerificationFailed({ message, errors });
193
194
  }
194
- return new UnknownProxmoxBackupError({ status, message, body });
195
- },
196
- });
195
+ return new BadRequest({ message });
196
+ }
197
+ return new UnknownProxmoxBackupError({ status, message, body });
198
+ },
199
+ });
200
+
201
+ /** PBS-specific form encoding and schema validation wrap core without changing other vendors. */
202
+ export const ProxmoxBackupProtocol: Layer.Layer<API.Protocol> =
203
+ withPbsCodecs(baseProtocol);