@distilled.cloud/acme 0.0.0-placeholder → 1.0.0-rc.13
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/LICENSE +201 -0
- package/README.md +73 -0
- package/lib/credentials.d.ts +50 -0
- package/lib/credentials.d.ts.map +1 -0
- package/lib/credentials.js +57 -0
- package/lib/credentials.js.map +1 -0
- package/lib/errors.d.ts +73 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +50 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +30 -0
- package/lib/index.js.map +1 -0
- package/lib/jose.d.ts +66 -0
- package/lib/jose.d.ts.map +1 -0
- package/lib/jose.js +128 -0
- package/lib/jose.js.map +1 -0
- package/lib/protocol.d.ts +27 -0
- package/lib/protocol.d.ts.map +1 -0
- package/lib/protocol.js +410 -0
- package/lib/protocol.js.map +1 -0
- package/lib/response-validation.test.d.ts +4 -0
- package/lib/response-validation.test.d.ts.map +1 -0
- package/lib/response-validation.test.js +51 -0
- package/lib/response-validation.test.js.map +1 -0
- package/lib/retry.d.ts +33 -0
- package/lib/retry.d.ts.map +1 -0
- package/lib/retry.js +33 -0
- package/lib/retry.js.map +1 -0
- package/lib/services/acme.d.ts +446 -0
- package/lib/services/acme.d.ts.map +1 -0
- package/lib/services/acme.js +597 -0
- package/lib/services/acme.js.map +1 -0
- package/lib/services/index.d.ts +2 -0
- package/lib/services/index.d.ts.map +1 -0
- package/lib/services/index.js +3 -0
- package/lib/services/index.js.map +1 -0
- package/lib/traits.d.ts +13 -0
- package/lib/traits.d.ts.map +1 -0
- package/lib/traits.js +13 -0
- package/lib/traits.js.map +1 -0
- package/package.json +75 -7
- package/src/credentials.ts +106 -0
- package/src/errors.ts +101 -0
- package/src/index.ts +33 -0
- package/src/jose.ts +234 -0
- package/src/protocol.ts +582 -0
- package/src/response-validation.test.ts +65 -0
- package/src/retry.ts +55 -0
- package/src/services/acme.ts +1140 -0
- package/src/services/index.ts +2 -0
- package/src/traits.ts +46 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AcmeProtocol — hand-written.
|
|
3
|
+
*
|
|
4
|
+
* ACME (RFC 8555) is JSON over HTTPS with two twists the generic REST
|
|
5
|
+
* protocol cannot express:
|
|
6
|
+
*
|
|
7
|
+
* signing: every POST body is a flattened JWS (RFC 8555 §6.2) signed by
|
|
8
|
+
* the account key from `Credentials`, carrying a fresh
|
|
9
|
+
* anti-replay nonce and the request URL in its protected
|
|
10
|
+
* header. `newAccount` embeds the public JWK; every later
|
|
11
|
+
* request references the account URL as `kid`. A CA-issued
|
|
12
|
+
* External Account Binding is attached to `newAccount` when
|
|
13
|
+
* the credentials carry one. Reads are "POST-as-GET" (empty
|
|
14
|
+
* payload).
|
|
15
|
+
*
|
|
16
|
+
* addressing: the provider endpoint is the CA's **directory URL**. The
|
|
17
|
+
* protocol fetches the directory once per CA and resolves
|
|
18
|
+
* `newNonce`/`newAccount`/`newOrder`/`revokeCert` from it;
|
|
19
|
+
* every other operation names its resource by the absolute
|
|
20
|
+
* URL the CA returned (`url` label).
|
|
21
|
+
*
|
|
22
|
+
* Responses are JSON; `Location` (account/order URL), `Replay-Nonce` and
|
|
23
|
+
* `Link` headers are folded into declared output members. Failures are
|
|
24
|
+
* RFC 7807 problem documents matched on their `type` URN against the
|
|
25
|
+
* operation's typed error classes; an unmatched URN is `UnknownAcmeError`.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
isStrict,
|
|
29
|
+
validateResponse,
|
|
30
|
+
} from "@distilled.cloud/core/response-validation";
|
|
31
|
+
import * as Effect from "effect/Effect";
|
|
32
|
+
import * as Layer from "effect/Layer";
|
|
33
|
+
import * as Redacted from "effect/Redacted";
|
|
34
|
+
import * as Schema from "effect/Schema";
|
|
35
|
+
import type * as HttpBody from "effect/http/HttpBody";
|
|
36
|
+
import type * as AST from "effect/SchemaAST";
|
|
37
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
38
|
+
import type * as HttpClientError from "effect/http/HttpClientError";
|
|
39
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
40
|
+
import type * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
41
|
+
import * as API from "@distilled.cloud/core/api";
|
|
42
|
+
import {
|
|
43
|
+
getAnn,
|
|
44
|
+
getProps,
|
|
45
|
+
hasPropAnn,
|
|
46
|
+
mapKeys,
|
|
47
|
+
} from "@distilled.cloud/core/protocol-http";
|
|
48
|
+
import { unwrapRedactedDeep } from "@distilled.cloud/core/protocol-rest";
|
|
49
|
+
import {
|
|
50
|
+
getErrorMatchers,
|
|
51
|
+
httpSymbol,
|
|
52
|
+
labelSymbol,
|
|
53
|
+
} from "@distilled.cloud/core/trait";
|
|
54
|
+
import {
|
|
55
|
+
HTTP_STATUS_MAP,
|
|
56
|
+
InternalServerError,
|
|
57
|
+
type ConfigError,
|
|
58
|
+
} from "@distilled.cloud/core/errors";
|
|
59
|
+
import {
|
|
60
|
+
parseRetryAfter,
|
|
61
|
+
parseRetryAfterForStatus,
|
|
62
|
+
} from "@distilled.cloud/core/retry-after";
|
|
63
|
+
import { Credentials, type Config } from "./credentials.ts";
|
|
64
|
+
import {
|
|
65
|
+
AcmeParseError,
|
|
66
|
+
DirectoryMissingResource,
|
|
67
|
+
JoseError,
|
|
68
|
+
UnknownAcmeError,
|
|
69
|
+
type DefaultErrors,
|
|
70
|
+
} from "./errors.ts";
|
|
71
|
+
import {
|
|
72
|
+
parseJwk,
|
|
73
|
+
signExternalAccountBinding,
|
|
74
|
+
signRequest,
|
|
75
|
+
type SignOptions,
|
|
76
|
+
} from "./jose.ts";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Error channel shared by every generated ACME operation. Generated service
|
|
80
|
+
* files annotate operations with `API.OperationMethod<I, O, AcmeOpError,
|
|
81
|
+
* AcmeOpContext>` explicitly so the compiler never infers these back out of
|
|
82
|
+
* the schema generics.
|
|
83
|
+
*/
|
|
84
|
+
export type AcmeOpError =
|
|
85
|
+
| DefaultErrors
|
|
86
|
+
| ConfigError
|
|
87
|
+
| HttpClientError.HttpClientError;
|
|
88
|
+
|
|
89
|
+
/** Context (requirements) shared by every generated ACME operation. */
|
|
90
|
+
export type AcmeOpContext = Credentials | HttpClient.HttpClient;
|
|
91
|
+
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// Directory + nonce caches (per CA, process-wide)
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
const DirectorySchema = Schema.Struct({
|
|
97
|
+
newNonce: Schema.String,
|
|
98
|
+
newAccount: Schema.String,
|
|
99
|
+
newOrder: Schema.String,
|
|
100
|
+
revokeCert: Schema.String,
|
|
101
|
+
newAuthz: Schema.optional(Schema.String),
|
|
102
|
+
keyChange: Schema.optional(Schema.String),
|
|
103
|
+
meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
104
|
+
});
|
|
105
|
+
type Directory = typeof DirectorySchema.Type;
|
|
106
|
+
|
|
107
|
+
const directories = new Map<string, Directory>();
|
|
108
|
+
/** The last unused `Replay-Nonce` per directory URL. Nonces are single-use. */
|
|
109
|
+
const nonces = new Map<string, string>();
|
|
110
|
+
|
|
111
|
+
type SigningContext = Omit<SignOptions, "nonce"> & {
|
|
112
|
+
readonly directoryUrl: string;
|
|
113
|
+
};
|
|
114
|
+
// HTTP request copies preserve the body identity; entries live only with a request.
|
|
115
|
+
const signedRequests = new WeakMap<HttpBody.HttpBody, SigningContext>();
|
|
116
|
+
|
|
117
|
+
const resolveCredentials = Effect.gen(function* () {
|
|
118
|
+
const resolve = yield* Credentials;
|
|
119
|
+
return yield* resolve as Effect.Effect<Config>;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const fetchDirectory = (directoryUrl: string) =>
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
const cached = directories.get(directoryUrl);
|
|
125
|
+
if (cached) return cached;
|
|
126
|
+
const client = yield* HttpClient.HttpClient;
|
|
127
|
+
const response = yield* client.get(directoryUrl);
|
|
128
|
+
rememberNonce(directoryUrl, response);
|
|
129
|
+
if (response.status >= 400) {
|
|
130
|
+
const text = yield* response.text;
|
|
131
|
+
return yield* fail(
|
|
132
|
+
new UnknownAcmeError({
|
|
133
|
+
message: `GET ${directoryUrl} answered ${response.status}`,
|
|
134
|
+
status: response.status,
|
|
135
|
+
body: Redacted.make(text),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const text = yield* response.text;
|
|
140
|
+
const json = yield* parseJson(text);
|
|
141
|
+
const directory = yield* Schema.decodeUnknownEffect(DirectorySchema)(
|
|
142
|
+
json,
|
|
143
|
+
).pipe(
|
|
144
|
+
Effect.mapError(() => parseError("Invalid ACME directory response")),
|
|
145
|
+
);
|
|
146
|
+
directories.set(directoryUrl, directory);
|
|
147
|
+
return directory;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const rememberNonce = (
|
|
151
|
+
directoryUrl: string,
|
|
152
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
153
|
+
): void => {
|
|
154
|
+
const nonce = response.headers["replay-nonce"];
|
|
155
|
+
if (typeof nonce === "string" && nonce.length > 0) {
|
|
156
|
+
nonces.set(directoryUrl, nonce);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Take the cached nonce for this CA, or fetch a fresh one from `newNonce`. */
|
|
161
|
+
const takeNonce = (directoryUrl: string, directory: Directory) =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const cached = nonces.get(directoryUrl);
|
|
164
|
+
if (cached !== undefined) {
|
|
165
|
+
nonces.delete(directoryUrl);
|
|
166
|
+
return cached;
|
|
167
|
+
}
|
|
168
|
+
const client = yield* HttpClient.HttpClient;
|
|
169
|
+
const response = yield* client.execute(
|
|
170
|
+
HttpClientRequest.head(directory.newNonce),
|
|
171
|
+
);
|
|
172
|
+
const nonce = response.headers["replay-nonce"];
|
|
173
|
+
if (typeof nonce !== "string" || nonce.length === 0) {
|
|
174
|
+
return yield* fail(
|
|
175
|
+
new UnknownAcmeError({
|
|
176
|
+
message: `HEAD ${directory.newNonce} returned no Replay-Nonce header`,
|
|
177
|
+
status: response.status,
|
|
178
|
+
body: undefined,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return nonce;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// =============================================================================
|
|
186
|
+
// Encode
|
|
187
|
+
// =============================================================================
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The generated operations carry no name at runtime; the input's `T.Http`
|
|
191
|
+
* uri (from the Smithy model) identifies which ACME resource an
|
|
192
|
+
* operation addresses.
|
|
193
|
+
*/
|
|
194
|
+
const OPERATIONS_BY_URI: Record<string, string> = {
|
|
195
|
+
"/directory": "GetDirectory",
|
|
196
|
+
"/newNonce": "NewNonce",
|
|
197
|
+
"/newAccount": "NewAccount",
|
|
198
|
+
"/account/{url}": "UpdateAccount",
|
|
199
|
+
"/newOrder": "NewOrder",
|
|
200
|
+
"/order/{url}": "GetOrder",
|
|
201
|
+
"/finalize/{url}": "FinalizeOrder",
|
|
202
|
+
"/authz/{url}": "GetAuthorization",
|
|
203
|
+
"/authz/{url}/deactivate": "DeactivateAuthorization",
|
|
204
|
+
"/challenge/{url}": "RespondChallenge",
|
|
205
|
+
"/cert/{url}": "DownloadCertificate",
|
|
206
|
+
"/revokeCert": "RevokeCertificate",
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const operationOf = (inputAst: AST.AST): string => {
|
|
210
|
+
const http = getAnn(inputAst, httpSymbol) as { uri?: string } | undefined;
|
|
211
|
+
return OPERATIONS_BY_URI[http?.uri ?? ""] ?? "";
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Operations resolved from the directory rather than an input `url`. */
|
|
215
|
+
const DIRECTORY_OPERATIONS: Record<string, keyof Directory> = {
|
|
216
|
+
NewNonce: "newNonce",
|
|
217
|
+
NewAccount: "newAccount",
|
|
218
|
+
NewOrder: "newOrder",
|
|
219
|
+
RevokeCertificate: "revokeCert",
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Operations whose payload is the empty POST-as-GET. */
|
|
223
|
+
const POST_AS_GET = new Set([
|
|
224
|
+
"GetOrder",
|
|
225
|
+
"GetAuthorization",
|
|
226
|
+
"DownloadCertificate",
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const JOSE_ACCEPT = "application/json, application/pem-certificate-chain";
|
|
230
|
+
|
|
231
|
+
const encode = ({
|
|
232
|
+
input,
|
|
233
|
+
inputAst,
|
|
234
|
+
}: {
|
|
235
|
+
readonly input: unknown;
|
|
236
|
+
readonly inputAst: AST.AST;
|
|
237
|
+
readonly config: API.ProtocolOperationConfig;
|
|
238
|
+
}) =>
|
|
239
|
+
Effect.gen(function* () {
|
|
240
|
+
const creds = yield* resolveCredentials;
|
|
241
|
+
const operation = operationOf(inputAst);
|
|
242
|
+
if (operation === "") {
|
|
243
|
+
return yield* fail(
|
|
244
|
+
new JoseError({
|
|
245
|
+
message: "The operation's input carries no known ACME `T.Http` uri.",
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (operation === "GetDirectory") {
|
|
251
|
+
return HttpClientRequest.get(creds.directoryUrl).pipe(
|
|
252
|
+
HttpClientRequest.setHeader("Accept", "application/json"),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const directory = yield* fetchDirectory(creds.directoryUrl);
|
|
257
|
+
|
|
258
|
+
if (operation === "NewNonce") {
|
|
259
|
+
return HttpClientRequest.head(directory.newNonce);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Resolve the request URL: from the directory for the well-known
|
|
263
|
+
// resources, else the absolute `url` label the CA handed back.
|
|
264
|
+
const inputObj = (unwrapRedactedDeep(input) ?? {}) as Record<
|
|
265
|
+
string,
|
|
266
|
+
unknown
|
|
267
|
+
>;
|
|
268
|
+
const directoryKey = DIRECTORY_OPERATIONS[operation];
|
|
269
|
+
let url: string;
|
|
270
|
+
if (directoryKey !== undefined) {
|
|
271
|
+
const resolved = directory[directoryKey];
|
|
272
|
+
if (typeof resolved !== "string") {
|
|
273
|
+
return yield* fail(
|
|
274
|
+
new DirectoryMissingResource({
|
|
275
|
+
resource: directoryKey,
|
|
276
|
+
directoryUrl: creds.directoryUrl,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
url = resolved;
|
|
281
|
+
} else {
|
|
282
|
+
const label = getProps(inputAst).find((p) => hasPropAnn(p, labelSymbol));
|
|
283
|
+
const value = label ? inputObj[String(label.name)] : undefined;
|
|
284
|
+
if (typeof value !== "string") {
|
|
285
|
+
return yield* fail(
|
|
286
|
+
new JoseError({
|
|
287
|
+
message: `${operation} needs the resource URL the CA returned (\`url\`).`,
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
url = value;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The payload: every input member that isn't the URL label, or the
|
|
295
|
+
// empty POST-as-GET for reads.
|
|
296
|
+
let payload: unknown;
|
|
297
|
+
if (!POST_AS_GET.has(operation)) {
|
|
298
|
+
const body: Record<string, unknown> = {};
|
|
299
|
+
for (const prop of getProps(inputAst)) {
|
|
300
|
+
if (hasPropAnn(prop, labelSymbol)) continue;
|
|
301
|
+
const key = String(prop.name);
|
|
302
|
+
if (inputObj[key] !== undefined) body[key] = inputObj[key];
|
|
303
|
+
}
|
|
304
|
+
payload = body;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const jwk = yield* parseJwk(creds.accountKey);
|
|
308
|
+
// `newAccount` proves the key by embedding it; a CA that requires an
|
|
309
|
+
// External Account Binding gets it from the credentials unless the
|
|
310
|
+
// caller built one.
|
|
311
|
+
const embedKey =
|
|
312
|
+
operation === "NewAccount" || creds.accountUrl === undefined;
|
|
313
|
+
if (
|
|
314
|
+
operation === "NewAccount" &&
|
|
315
|
+
creds.externalAccountBinding !== undefined &&
|
|
316
|
+
(payload as Record<string, unknown>).externalAccountBinding === undefined
|
|
317
|
+
) {
|
|
318
|
+
(payload as Record<string, unknown>).externalAccountBinding =
|
|
319
|
+
yield* signExternalAccountBinding({
|
|
320
|
+
jwk,
|
|
321
|
+
url,
|
|
322
|
+
keyId: creds.externalAccountBinding.keyId,
|
|
323
|
+
hmacKey: creds.externalAccountBinding.hmacKey,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const nonce = yield* takeNonce(creds.directoryUrl, directory);
|
|
328
|
+
const signing: SigningContext = {
|
|
329
|
+
jwk,
|
|
330
|
+
url,
|
|
331
|
+
kid: embedKey ? undefined : creds.accountUrl,
|
|
332
|
+
payload,
|
|
333
|
+
directoryUrl: creds.directoryUrl,
|
|
334
|
+
};
|
|
335
|
+
const request = yield* signedRequest(signing, nonce);
|
|
336
|
+
signedRequests.set(request.body, signing);
|
|
337
|
+
return request;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const signedRequest = (signing: SigningContext, nonce: string) =>
|
|
341
|
+
Effect.gen(function* () {
|
|
342
|
+
const jws = yield* signRequest({ ...signing, nonce });
|
|
343
|
+
return HttpClientRequest.post(signing.url).pipe(
|
|
344
|
+
HttpClientRequest.setHeader("Accept", JOSE_ACCEPT),
|
|
345
|
+
HttpClientRequest.bodyText(JSON.stringify(jws), "application/jose+json"),
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// =============================================================================
|
|
350
|
+
// Decode
|
|
351
|
+
// =============================================================================
|
|
352
|
+
|
|
353
|
+
interface Problem {
|
|
354
|
+
readonly type?: string;
|
|
355
|
+
readonly detail?: string;
|
|
356
|
+
readonly status?: number;
|
|
357
|
+
readonly subproblems?: ReadonlyArray<unknown>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const isProblem = (value: unknown): value is Problem =>
|
|
361
|
+
isObject(value) && typeof value.type === "string";
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Match a problem document against the operation's typed error classes by
|
|
365
|
+
* its `type` URN (the matcher's `message` rule is applied to the URN, so a
|
|
366
|
+
* patch writes `{ "message": { "matches": "^urn:ietf:params:acme:error:badNonce$" } }`).
|
|
367
|
+
*/
|
|
368
|
+
const matchProblem = (
|
|
369
|
+
errorClasses: ReadonlyArray<unknown>,
|
|
370
|
+
status: number,
|
|
371
|
+
problem: Problem,
|
|
372
|
+
headers: Record<string, string | undefined>,
|
|
373
|
+
): unknown | undefined => {
|
|
374
|
+
const urn = problem.type ?? "";
|
|
375
|
+
let best: { cls: unknown; specificity: number } | undefined;
|
|
376
|
+
for (const cls of errorClasses) {
|
|
377
|
+
const matchers = getErrorMatchers(cls);
|
|
378
|
+
if (!matchers) continue;
|
|
379
|
+
for (const m of matchers) {
|
|
380
|
+
if (m.status !== undefined && m.status !== status) continue;
|
|
381
|
+
if (m.code !== undefined && m.code !== status) continue;
|
|
382
|
+
const rule = m.message;
|
|
383
|
+
let hit = false;
|
|
384
|
+
if (rule === undefined) hit = true;
|
|
385
|
+
else if (typeof rule === "string") hit = rule === urn;
|
|
386
|
+
else if (rule.matches !== undefined)
|
|
387
|
+
hit = new RegExp(rule.matches).test(urn);
|
|
388
|
+
else if (rule.includes !== undefined) hit = urn.includes(rule.includes);
|
|
389
|
+
if (!hit) continue;
|
|
390
|
+
const specificity =
|
|
391
|
+
(m.status !== undefined ? 1 : 0) + (rule !== undefined ? 2 : 0);
|
|
392
|
+
if (!best || specificity > best.specificity) best = { cls, specificity };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!best) return undefined;
|
|
396
|
+
return new (best.cls as new (args: any) => unknown)({
|
|
397
|
+
code: status,
|
|
398
|
+
message: problem.detail ?? urn,
|
|
399
|
+
type: urn,
|
|
400
|
+
detail: problem.detail,
|
|
401
|
+
subproblems: problem.subproblems,
|
|
402
|
+
...(urn === "urn:ietf:params:acme:error:rateLimited"
|
|
403
|
+
? { retryAfter: parseRetryAfter(headers) }
|
|
404
|
+
: {}),
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const parseLinkAlternates = (link: string | undefined): string[] => {
|
|
409
|
+
if (!link) return [];
|
|
410
|
+
const out: string[] = [];
|
|
411
|
+
for (const part of link.split(",")) {
|
|
412
|
+
const match = part.match(/<([^>]+)>\s*;\s*rel="?alternate"?/);
|
|
413
|
+
if (match) out.push(match[1]!);
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const decode = ({
|
|
419
|
+
response,
|
|
420
|
+
outputAst,
|
|
421
|
+
errors: errorClasses,
|
|
422
|
+
}: {
|
|
423
|
+
readonly response: HttpClientResponse.HttpClientResponse;
|
|
424
|
+
readonly outputAst: AST.AST;
|
|
425
|
+
readonly errors: ReadonlyArray<unknown>;
|
|
426
|
+
readonly config: API.ProtocolOperationConfig;
|
|
427
|
+
}) =>
|
|
428
|
+
Effect.gen(function* () {
|
|
429
|
+
const signing = signedRequests.get(response.request.body);
|
|
430
|
+
signedRequests.delete(response.request.body);
|
|
431
|
+
const directoryUrl =
|
|
432
|
+
signing?.directoryUrl ?? (yield* resolveCredentials).directoryUrl;
|
|
433
|
+
|
|
434
|
+
for (let retries = 0; ; retries++) {
|
|
435
|
+
const headers = response.headers as Record<string, string | undefined>;
|
|
436
|
+
const status = response.status;
|
|
437
|
+
const isNewNonce = response.request.method === "HEAD";
|
|
438
|
+
const isCertificate = (headers["content-type"] ?? "").includes(
|
|
439
|
+
"pem-certificate-chain",
|
|
440
|
+
);
|
|
441
|
+
const text = isNewNonce ? "" : yield* response.text;
|
|
442
|
+
const json =
|
|
443
|
+
isNewNonce || (isCertificate && status < 400)
|
|
444
|
+
? undefined
|
|
445
|
+
: yield* parseJson(text).pipe(
|
|
446
|
+
// A non-JSON 2xx fails only in strict mode; lenient returns
|
|
447
|
+
// the text as read.
|
|
448
|
+
Effect.catchTag("AcmeParseError", (error) =>
|
|
449
|
+
status >= 400
|
|
450
|
+
? Effect.succeed(undefined)
|
|
451
|
+
: Effect.flatMap(isStrict, (strict) =>
|
|
452
|
+
strict
|
|
453
|
+
? Effect.fail(error)
|
|
454
|
+
: Effect.succeed<unknown>(text),
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
const problem = status >= 400 && isProblem(json) ? json : undefined;
|
|
459
|
+
const badNonce = problem?.type === "urn:ietf:params:acme:error:badNonce";
|
|
460
|
+
if (badNonce) {
|
|
461
|
+
const nonce = headers["replay-nonce"];
|
|
462
|
+
// Rejection nonces belong only to this request, never to the shared cache.
|
|
463
|
+
if (signing && nonce && retries < 2) {
|
|
464
|
+
const client = yield* HttpClient.HttpClient;
|
|
465
|
+
response = yield* client.execute(
|
|
466
|
+
yield* signedRequest(signing, nonce),
|
|
467
|
+
);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
rememberNonce(directoryUrl, response);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (status >= 400) {
|
|
475
|
+
if (problem) {
|
|
476
|
+
const typed = matchProblem(errorClasses, status, problem, headers);
|
|
477
|
+
if (typed !== undefined) return yield* fail(typed);
|
|
478
|
+
return yield* fail(
|
|
479
|
+
new UnknownAcmeError({
|
|
480
|
+
type: problem.type,
|
|
481
|
+
detail: problem.detail,
|
|
482
|
+
subproblems: problem.subproblems,
|
|
483
|
+
message: problem.detail ?? problem.type,
|
|
484
|
+
status,
|
|
485
|
+
body: Redacted.make(json),
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const message = `HTTP ${status}`;
|
|
490
|
+
const StatusClass = (HTTP_STATUS_MAP as Record<number, unknown>)[
|
|
491
|
+
status
|
|
492
|
+
] as
|
|
493
|
+
| (new (args: {
|
|
494
|
+
message: string;
|
|
495
|
+
retryAfter?: ReturnType<typeof parseRetryAfterForStatus>;
|
|
496
|
+
}) => unknown)
|
|
497
|
+
| undefined;
|
|
498
|
+
if (StatusClass) {
|
|
499
|
+
return yield* fail(
|
|
500
|
+
new StatusClass({
|
|
501
|
+
message,
|
|
502
|
+
retryAfter: parseRetryAfterForStatus(status, headers),
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (status >= 500) {
|
|
507
|
+
return yield* fail(
|
|
508
|
+
new InternalServerError({
|
|
509
|
+
message,
|
|
510
|
+
retryAfter: parseRetryAfterForStatus(status, headers),
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return yield* fail(
|
|
515
|
+
new UnknownAcmeError({
|
|
516
|
+
message,
|
|
517
|
+
status,
|
|
518
|
+
body: Redacted.make(json ?? text),
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let body: Record<string, unknown>;
|
|
524
|
+
if (isNewNonce) {
|
|
525
|
+
body = { replayNonce: headers["replay-nonce"] || undefined };
|
|
526
|
+
} else if (isCertificate) {
|
|
527
|
+
body = {
|
|
528
|
+
chain: text,
|
|
529
|
+
alternates: parseLinkAlternates(headers["link"]),
|
|
530
|
+
};
|
|
531
|
+
} else if (isObject(json)) {
|
|
532
|
+
body = json;
|
|
533
|
+
const location = headers["location"];
|
|
534
|
+
if (location) body = { ...body, location };
|
|
535
|
+
} else {
|
|
536
|
+
// Lenient mode returns a non-object body as read.
|
|
537
|
+
if (!(yield* isStrict)) return json;
|
|
538
|
+
return yield* fail(parseError("Expected a JSON object"));
|
|
539
|
+
}
|
|
540
|
+
// Strict mode (core/response-validation) checks the output schema.
|
|
541
|
+
return yield* validateResponse(
|
|
542
|
+
outputAst,
|
|
543
|
+
mapKeys(outputAst, body, "decode"),
|
|
544
|
+
() => parseError("Response does not match the output schema"),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
550
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
551
|
+
|
|
552
|
+
const parseError = (cause: string) =>
|
|
553
|
+
new AcmeParseError({
|
|
554
|
+
body: "[REDACTED]",
|
|
555
|
+
cause,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const parseJson = (text: string) =>
|
|
559
|
+
Effect.try({
|
|
560
|
+
try: (): unknown => (text.trim().length > 0 ? JSON.parse(text) : {}),
|
|
561
|
+
catch: () => parseError("Invalid JSON response"),
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const fail = <E>(error: E) => Effect.fail(error) as Effect.Effect<never, E>;
|
|
565
|
+
|
|
566
|
+
export const AcmeProtocol: Layer.Layer<API.Protocol> = Layer.succeed(
|
|
567
|
+
API.Protocol,
|
|
568
|
+
API.Protocol.of({
|
|
569
|
+
encode: (args) =>
|
|
570
|
+
encode(args) as Effect.Effect<HttpClientRequest.HttpClientRequest>,
|
|
571
|
+
decode: (args) => decode(args) as Effect.Effect<unknown>,
|
|
572
|
+
}),
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
/** Forget cached directories and nonces (tests, CA switches). */
|
|
576
|
+
export const resetProtocolCaches = (): void => {
|
|
577
|
+
directories.clear();
|
|
578
|
+
nonces.clear();
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/** @internal re-exported for tests */
|
|
582
|
+
export const _internal = { getAnn, Redacted };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { runValidationModes } from "@distilled.cloud/core/testing";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Redacted from "effect/Redacted";
|
|
5
|
+
import { layer } from "./credentials.ts";
|
|
6
|
+
import { AcmeParseError } from "./errors.ts";
|
|
7
|
+
import { resetProtocolCaches } from "./protocol.ts";
|
|
8
|
+
import * as Retry from "./retry.ts";
|
|
9
|
+
import { getDirectory } from "./services/acme.ts";
|
|
10
|
+
import type { AcmeOpError } from "./protocol.ts";
|
|
11
|
+
|
|
12
|
+
const DIRECTORY_URL = "https://acme.test/directory";
|
|
13
|
+
|
|
14
|
+
// getDirectory is a plain GET of the directory URL: no nonce, no signing.
|
|
15
|
+
const TestCredentials = layer({
|
|
16
|
+
directoryUrl: DIRECTORY_URL,
|
|
17
|
+
accountKey: Redacted.make("{}"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// getDirectory declares `{ newNonce: string; newAccount: string; newOrder: string; revokeCert: string; … }`.
|
|
21
|
+
const run = (body: string) =>
|
|
22
|
+
runValidationModes(
|
|
23
|
+
getDirectory({}).pipe(Retry.none, Effect.provide(TestCredentials)),
|
|
24
|
+
(request) => {
|
|
25
|
+
if (request.url !== DIRECTORY_URL) {
|
|
26
|
+
throw new Error(`unexpected request to ${request.url}`);
|
|
27
|
+
}
|
|
28
|
+
return { body };
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => resetProtocolCaches());
|
|
33
|
+
|
|
34
|
+
describe("ACME response validation", () => {
|
|
35
|
+
test("a matching body succeeds unchanged in both modes", async () => {
|
|
36
|
+
const body = {
|
|
37
|
+
newNonce: "https://acme.test/new-nonce",
|
|
38
|
+
newAccount: "https://acme.test/new-account",
|
|
39
|
+
newOrder: "https://acme.test/new-order",
|
|
40
|
+
revokeCert: "https://acme.test/revoke-cert",
|
|
41
|
+
};
|
|
42
|
+
const { lenient, strict } = await run(JSON.stringify(body));
|
|
43
|
+
expect(lenient).toMatchObject({ _tag: "Success", success: body });
|
|
44
|
+
expect(strict).toMatchObject({ _tag: "Success", success: body });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a body missing required members: lenient returns it, strict fails", async () => {
|
|
48
|
+
const body = { newNonce: "https://acme.test/new-nonce" };
|
|
49
|
+
const { lenient, strict } = await run(JSON.stringify(body));
|
|
50
|
+
expect(lenient).toMatchObject({ _tag: "Success", success: body });
|
|
51
|
+
expect(strict._tag).toBe("Failure");
|
|
52
|
+
expect((strict as any).failure).toBeInstanceOf(AcmeParseError);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a non-JSON body: lenient returns the text, strict fails", async () => {
|
|
56
|
+
const { lenient, strict } = await run("not json");
|
|
57
|
+
expect(lenient).toMatchObject({ _tag: "Success", success: "not json" });
|
|
58
|
+
expect((strict as any).failure).toBeInstanceOf(AcmeParseError);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// AcmeParseError is part of every operation's declared error type.
|
|
63
|
+
export const parseErrorIsDeclared: [AcmeParseError] extends [AcmeOpError]
|
|
64
|
+
? true
|
|
65
|
+
: false = true;
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME retry configuration.
|
|
3
|
+
*
|
|
4
|
+
* Defines the per-SDK `Retry` Context.Service tag that generated operations
|
|
5
|
+
* wire into `API.make({ retry: Retry.Retry })`. Callers can install a
|
|
6
|
+
* blanket retry policy at the layer level and have every ACME call
|
|
7
|
+
* below it pick it up:
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import * as Acme from "@distilled.cloud/acme";
|
|
12
|
+
*
|
|
13
|
+
* myEffect.pipe(Acme.Retry.transient);
|
|
14
|
+
* Effect.provide(myEffect, Layer.succeed(Acme.Retry.Retry, customPolicy));
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
import * as Context from "effect/Context";
|
|
18
|
+
import * as Effect from "effect/Effect";
|
|
19
|
+
import * as Layer from "effect/Layer";
|
|
20
|
+
import {
|
|
21
|
+
type Policy,
|
|
22
|
+
throttlingFactory,
|
|
23
|
+
transientFactory,
|
|
24
|
+
} from "@distilled.cloud/core/retry";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
type Options,
|
|
28
|
+
type Factory,
|
|
29
|
+
type Policy,
|
|
30
|
+
makeDefault,
|
|
31
|
+
jittered,
|
|
32
|
+
capped,
|
|
33
|
+
throttlingOptions,
|
|
34
|
+
transientOptions,
|
|
35
|
+
throttlingFactory,
|
|
36
|
+
transientFactory,
|
|
37
|
+
} from "@distilled.cloud/core/retry";
|
|
38
|
+
|
|
39
|
+
/** Context tag for configuring retry behavior of ACME calls. */
|
|
40
|
+
export class Retry extends Context.Service<Retry, Policy>()("AcmeRetry") {}
|
|
41
|
+
|
|
42
|
+
/** Provides a custom retry policy to every ACME call below it. */
|
|
43
|
+
export const policy = (optionsOrFactory: Policy) =>
|
|
44
|
+
Effect.provide(Layer.succeed(Retry, optionsOrFactory));
|
|
45
|
+
|
|
46
|
+
/** Disables all automatic retries. */
|
|
47
|
+
export const none = Effect.provide(
|
|
48
|
+
Layer.succeed(Retry, { while: () => false }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** Apply the throttling retry policy (retries throttling errors indefinitely). */
|
|
52
|
+
export const throttling = policy(throttlingFactory);
|
|
53
|
+
|
|
54
|
+
/** Apply the transient retry policy (retries all transient errors indefinitely). */
|
|
55
|
+
export const transient = policy(transientFactory);
|