@pithy-sh/cloudflare 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/errors.d.ts +144 -2
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +416 -7
- package/dist/media/assetSeeder.d.ts.map +1 -1
- package/dist/media/assetSeeder.js +19 -5
- package/dist/test-utils/emailRoutingRules.d.ts.map +1 -1
- package/dist/test-utils/emailRoutingRules.js +19 -6
- package/dist/test-utils/inboundRecorder.d.ts.map +1 -1
- package/dist/test-utils/inboundRecorder.js +19 -6
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +40 -17
- package/dist/workers/buildsManager.d.ts.map +1 -1
- package/dist/workers/buildsManager.js +9 -5
- package/dist/workers/workersManager.d.ts +19 -0
- package/dist/workers/workersManager.d.ts.map +1 -1
- package/dist/workers/workersManager.js +33 -1
- package/dist/workflows/stepFailure.d.ts +28 -0
- package/dist/workflows/stepFailure.d.ts.map +1 -1
- package/dist/workflows/stepFailure.js +44 -2
- package/package.json +6 -5
- package/src/client/errors.ts +497 -8
- package/src/media/assetSeeder.ts +22 -4
- package/src/test-utils/emailRoutingRules.ts +19 -5
- package/src/test-utils/inboundRecorder.ts +19 -5
- package/src/tokens/accountTokensManager.ts +118 -54
- package/src/workers/buildsManager.ts +21 -6
- package/src/workers/workersManager.ts +41 -1
- package/src/workflows/stepFailure.ts +44 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { CloudflareInvalidResponseError,
|
|
5
|
+
import { CloudflareInvalidResponseError, cloudflareApiErrors, cloudflareRefusal } from "../client/errors";
|
|
6
6
|
import type { CloudflareWorkersManager } from "../workers/workersManager";
|
|
7
7
|
import type { IntegrationCreds } from "./harness";
|
|
8
8
|
|
|
@@ -169,10 +169,15 @@ export async function readObservedInbound(options: {
|
|
|
169
169
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
|
|
170
170
|
if (response.status === 404) return null;
|
|
171
171
|
if (!response.ok) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
// Composed where every Cloudflare refusal is — see `client/errors.ts`. The polling loop reads this
|
|
173
|
+
// on every failed attempt, so it is worth the code Cloudflare actually answered with.
|
|
174
|
+
const failure = await response.text();
|
|
175
|
+
throw cloudflareRefusal({
|
|
176
|
+
problem: "Could not read the recorded inbound message.",
|
|
177
|
+
apiErrors: cloudflareApiErrors(parseEnvelope(failure)),
|
|
178
|
+
status: response.status,
|
|
179
|
+
permission: "Workers KV Storage",
|
|
180
|
+
detail: `KV read for the inbound record returned ${response.status}: ${failure}`.slice(0, 2000),
|
|
176
181
|
});
|
|
177
182
|
}
|
|
178
183
|
|
|
@@ -187,3 +192,12 @@ export async function readObservedInbound(options: {
|
|
|
187
192
|
}
|
|
188
193
|
return ObservedInbound.parse(body);
|
|
189
194
|
}
|
|
195
|
+
|
|
196
|
+
/** A CF response body as an object, or `undefined` when it is not JSON — `cloudflareApiErrors` reads either. */
|
|
197
|
+
function parseEnvelope(body: string): unknown {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(body);
|
|
200
|
+
} catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -4,15 +4,34 @@
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import {
|
|
6
6
|
CloudflareNotConfiguredError,
|
|
7
|
-
|
|
7
|
+
cloudflareApiErrors,
|
|
8
|
+
cloudflareRefusal,
|
|
8
9
|
cloudflareRequest,
|
|
10
|
+
cloudflareSaid,
|
|
9
11
|
decodeResponse,
|
|
10
12
|
isAuthorizationError,
|
|
11
13
|
messageOf,
|
|
14
|
+
statusOf,
|
|
12
15
|
} from "../client/errors";
|
|
13
16
|
import { CloudflareManager } from "../client/manager";
|
|
14
17
|
import { CfTokenVerification } from "../user/userManager";
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The account permission group behind every endpoint in this file, handed to `cloudflareRequest` so an
|
|
21
|
+
* auth-class refusal names it.
|
|
22
|
+
*
|
|
23
|
+
* It has to be stated rather than derived, because Cloudflare's `documentation_url` for
|
|
24
|
+
* `accounts/tokens` yields `accounts` — a segment `ACCOUNT_PERMISSIONS` deliberately does not map, since
|
|
25
|
+
* account-scoped endpoints span every product. And it has to be on **every** call here rather than only
|
|
26
|
+
* on the mint: `pithy token mint` is `rollToken → findTokenByName → listTokens`, then
|
|
27
|
+
* `listPermissionGroups`, and every one of those runs before `tokens.create` on the same credential. A
|
|
28
|
+
* hint reachable only from the mint is a hint an under-scoped bootstrap token never reaches (#534).
|
|
29
|
+
*
|
|
30
|
+
* `verifyToken` is the deliberate omission: any account token may call `/tokens/verify` with no
|
|
31
|
+
* permission at all, so a refusal there is a dead token or the wrong account, never a missing grant.
|
|
32
|
+
*/
|
|
33
|
+
const API_TOKENS_ENDPOINT = { permission: "API Tokens" } as const;
|
|
34
|
+
|
|
16
35
|
/**
|
|
17
36
|
* One access policy to attach to a minted token: a set of permission groups (named, resolved to ids
|
|
18
37
|
* at mint time) and the resources they apply to. This is the reusable unit — every use case that
|
|
@@ -132,30 +151,38 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
132
151
|
* with no permission at all. Every other failure still throws.
|
|
133
152
|
*/
|
|
134
153
|
async getTokenName(tokenId: string): Promise<string | null> {
|
|
135
|
-
const raw = await cloudflareRequest(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
const raw = await cloudflareRequest(
|
|
155
|
+
`get account token ${tokenId}`,
|
|
156
|
+
async () => {
|
|
157
|
+
try {
|
|
158
|
+
return await this.getClient().accounts.tokens.get(tokenId, { account_id: this.accountId });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (isAuthorizationError(error)) return null;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
API_TOKENS_ENDPOINT,
|
|
165
|
+
);
|
|
143
166
|
const parsed = TokenName.safeParse(raw);
|
|
144
167
|
return parsed.success ? parsed.data.name : null;
|
|
145
168
|
}
|
|
146
169
|
|
|
147
170
|
/** Every permission group available to account-owned tokens in this account (SDK auto-paginates). */
|
|
148
171
|
async listPermissionGroups(): Promise<CfPermissionGroup[]> {
|
|
149
|
-
return cloudflareRequest(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
172
|
+
return cloudflareRequest(
|
|
173
|
+
"list account token permission groups",
|
|
174
|
+
async () => {
|
|
175
|
+
const out: CfPermissionGroup[] = [];
|
|
176
|
+
for await (const group of this.getClient().accounts.tokens.permissionGroups.list({
|
|
177
|
+
account_id: this.accountId,
|
|
178
|
+
})) {
|
|
179
|
+
const parsed = CfPermissionGroup.safeParse(group);
|
|
180
|
+
if (parsed.success) out.push(parsed.data);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
},
|
|
184
|
+
API_TOKENS_ENDPOINT,
|
|
185
|
+
);
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
/**
|
|
@@ -189,19 +216,40 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
189
216
|
raw = await this.getClient().accounts.tokens.create({ account_id: this.accountId, name, policies });
|
|
190
217
|
} catch (error) {
|
|
191
218
|
if (isAuthorizationError(error)) {
|
|
219
|
+
// The 403 keeps its own diagnosis — the token got in and may not create tokens, which is more
|
|
220
|
+
// than the generic three-way refusal can say — and it no longer keeps it *instead of*
|
|
221
|
+
// Cloudflare's answer. Round two moved the fallback below onto the shared composer and left
|
|
222
|
+
// this branch composing its own sentence, so the one status that carries a real diagnosis was
|
|
223
|
+
// also the one that dropped the API's `errors[]` on the floor. `cloudflareSaid` is that
|
|
224
|
+
// composition, exported for exactly this: a call site with a better problem line still gets
|
|
225
|
+
// the API's words, on one line, bounded, with the structured fields in `params`.
|
|
226
|
+
const said = cloudflareSaid(
|
|
227
|
+
"The Cloudflare API token is not allowed to create account tokens.",
|
|
228
|
+
cloudflareApiErrors(error),
|
|
229
|
+
);
|
|
192
230
|
throw new CloudflareNotConfiguredError(
|
|
193
231
|
{
|
|
194
|
-
message:
|
|
232
|
+
message: said.message,
|
|
195
233
|
action: "Grant it 'Account API Tokens Write' (Account → API Tokens → Edit), then re-run.",
|
|
234
|
+
params: said.params,
|
|
196
235
|
detail: `mint account token '${name}': ${messageOf(error)}`,
|
|
197
236
|
},
|
|
198
237
|
{ cause: error },
|
|
199
238
|
);
|
|
200
239
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
240
|
+
// The shared composer, never a hand-built refusal. The branch above is 403-only on purpose —
|
|
241
|
+
// `isAuthorizationError` stays strictly 403 because its other callers *swallow* a denial — and a
|
|
242
|
+
// 401 is the status the token bootstrap actually fails under, so every 401 landed here and
|
|
243
|
+
// printed one sentence with no code, no link and nothing to do. `permission` names the group
|
|
244
|
+
// this endpoint needs, because Cloudflare's docs link for `accounts/tokens` does not.
|
|
245
|
+
throw cloudflareRefusal({
|
|
246
|
+
problem: `Failed to mint account token '${name}'.`,
|
|
247
|
+
apiErrors: cloudflareApiErrors(error),
|
|
248
|
+
status: statusOf(error),
|
|
249
|
+
permission: "API Tokens",
|
|
250
|
+
detail: messageOf(error),
|
|
251
|
+
cause: error,
|
|
252
|
+
});
|
|
205
253
|
}
|
|
206
254
|
return decodeResponse(MintedAccountToken, raw, "account token create");
|
|
207
255
|
}
|
|
@@ -235,31 +283,41 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
235
283
|
|
|
236
284
|
/** Every account-owned token's metadata (SDK auto-paginates). No secret values — list is metadata only. */
|
|
237
285
|
async listTokens(): Promise<AccountTokenSummary[]> {
|
|
238
|
-
return cloudflareRequest(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
286
|
+
return cloudflareRequest(
|
|
287
|
+
"list account tokens",
|
|
288
|
+
async () => {
|
|
289
|
+
const out: AccountTokenSummary[] = [];
|
|
290
|
+
for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
|
|
291
|
+
const parsed = AccountTokenSummary.safeParse(token);
|
|
292
|
+
if (parsed.success) out.push(parsed.data);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
},
|
|
296
|
+
API_TOKENS_ENDPOINT,
|
|
297
|
+
);
|
|
246
298
|
}
|
|
247
299
|
|
|
248
300
|
/** Find an account token by exact name, or `null` — for idempotent re-mint (SDK auto-paginates). */
|
|
249
301
|
async findTokenByName(name: string): Promise<AccountTokenSummary | null> {
|
|
250
|
-
return cloudflareRequest(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
302
|
+
return cloudflareRequest(
|
|
303
|
+
`find account token ${name}`,
|
|
304
|
+
async () => {
|
|
305
|
+
for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
|
|
306
|
+
const parsed = AccountTokenSummary.safeParse(token);
|
|
307
|
+
if (parsed.success && parsed.data.name === name) return parsed.data;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
},
|
|
311
|
+
API_TOKENS_ENDPOINT,
|
|
312
|
+
);
|
|
257
313
|
}
|
|
258
314
|
|
|
259
315
|
/** Delete an account token by id. */
|
|
260
316
|
async deleteToken(tokenId: string): Promise<void> {
|
|
261
|
-
await cloudflareRequest(
|
|
262
|
-
|
|
317
|
+
await cloudflareRequest(
|
|
318
|
+
`delete account token ${tokenId}`,
|
|
319
|
+
() => this.getClient().accounts.tokens.delete(tokenId, { account_id: this.accountId }),
|
|
320
|
+
API_TOKENS_ENDPOINT,
|
|
263
321
|
);
|
|
264
322
|
}
|
|
265
323
|
|
|
@@ -269,17 +327,21 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
269
327
|
* when none match. Used by teardown.
|
|
270
328
|
*/
|
|
271
329
|
async deleteTokensByName(name: string): Promise<number> {
|
|
272
|
-
return cloudflareRequest(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
330
|
+
return cloudflareRequest(
|
|
331
|
+
`delete account tokens named ${name}`,
|
|
332
|
+
async () => {
|
|
333
|
+
const ids: string[] = [];
|
|
334
|
+
for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
|
|
335
|
+
const parsed = AccountTokenSummary.safeParse(token);
|
|
336
|
+
if (parsed.success && parsed.data.name === name) ids.push(parsed.data.id);
|
|
337
|
+
}
|
|
338
|
+
for (const id of ids) {
|
|
339
|
+
await this.getClient().accounts.tokens.delete(id, { account_id: this.accountId });
|
|
340
|
+
}
|
|
341
|
+
return ids.length;
|
|
342
|
+
},
|
|
343
|
+
API_TOKENS_ENDPOINT,
|
|
344
|
+
);
|
|
283
345
|
}
|
|
284
346
|
|
|
285
347
|
/**
|
|
@@ -289,8 +351,10 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
289
351
|
* builds on: a credential self-rolls without ever changing identity.
|
|
290
352
|
*/
|
|
291
353
|
async rollTokenValue(tokenId: string): Promise<string> {
|
|
292
|
-
const raw = await cloudflareRequest(
|
|
293
|
-
|
|
354
|
+
const raw = await cloudflareRequest(
|
|
355
|
+
`roll account token value ${tokenId}`,
|
|
356
|
+
() => this.getClient().accounts.tokens.value.update(tokenId, { account_id: this.accountId, body: {} }),
|
|
357
|
+
API_TOKENS_ENDPOINT,
|
|
294
358
|
);
|
|
295
359
|
return decodeResponse(RolledTokenValue, raw, "account token value roll");
|
|
296
360
|
}
|
|
@@ -5,6 +5,8 @@ import { z } from "zod";
|
|
|
5
5
|
import {
|
|
6
6
|
CloudflareInvalidResponseError,
|
|
7
7
|
CloudflareRequestError,
|
|
8
|
+
cloudflareApiErrors,
|
|
9
|
+
cloudflareRefusal,
|
|
8
10
|
cloudflareRequest,
|
|
9
11
|
decodeResponse,
|
|
10
12
|
messageOf,
|
|
@@ -32,7 +34,17 @@ const NOT_FOUND = 12000;
|
|
|
32
34
|
const CfEnvelope = z.object({
|
|
33
35
|
result: z.unknown(),
|
|
34
36
|
success: z.boolean().optional(),
|
|
35
|
-
errors: z
|
|
37
|
+
errors: z
|
|
38
|
+
.array(
|
|
39
|
+
z
|
|
40
|
+
.object({
|
|
41
|
+
code: z.number().optional(),
|
|
42
|
+
message: z.string().optional(),
|
|
43
|
+
documentation_url: z.string().optional(),
|
|
44
|
+
})
|
|
45
|
+
.loose(),
|
|
46
|
+
)
|
|
47
|
+
.optional(),
|
|
36
48
|
});
|
|
37
49
|
|
|
38
50
|
/** Arguments for `createRepoConnection`. */
|
|
@@ -138,12 +150,15 @@ export class CloudflareBuildsManager extends CloudflareManager {
|
|
|
138
150
|
// CF signals failure two ways: a non-2xx status, or a 200 body with `success: false`.
|
|
139
151
|
const apiReportedFailure = envelope.success && envelope.data.success === false;
|
|
140
152
|
if (!response.ok || apiReportedFailure) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
: [];
|
|
153
|
+
const errors = envelope.success ? (envelope.data.errors ?? []) : [];
|
|
154
|
+
const codes = errors.map((e) => e.code).filter((c): c is number => c !== undefined);
|
|
144
155
|
const marker = codes.length > 0 ? `[cf-codes:${codes.join(",")}] ` : "";
|
|
145
|
-
|
|
146
|
-
|
|
156
|
+
// The shared composer, so the one manager on the raw-`fetch` escape hatch refuses with the same
|
|
157
|
+
// projection of Cloudflare's `errors[]` as the 133 calls that go through `cloudflareRequest`.
|
|
158
|
+
throw cloudflareRefusal({
|
|
159
|
+
problem: `Cloudflare Builds returned ${response.status}.`,
|
|
160
|
+
apiErrors: cloudflareApiErrors(errors),
|
|
161
|
+
status: response.status,
|
|
147
162
|
detail: `${marker}${method} ${path} → ${response.status}: ${text}`.slice(0, 2000),
|
|
148
163
|
});
|
|
149
164
|
}
|
|
@@ -5,11 +5,12 @@ import { NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyEr
|
|
|
5
5
|
import type { Cloudflare } from "cloudflare";
|
|
6
6
|
import type { RouteCreateResponse, RouteListResponse } from "cloudflare/resources/workers/routes";
|
|
7
7
|
import type { Deployment } from "cloudflare/resources/workers/scripts/deployments";
|
|
8
|
+
import type { ScriptAndVersionSettingGetResponse } from "cloudflare/resources/workers/scripts/script-and-version-settings";
|
|
8
9
|
import type { Script } from "cloudflare/resources/workers/scripts/scripts";
|
|
9
10
|
import type { SecretListResponse } from "cloudflare/resources/workers/scripts/secrets";
|
|
10
11
|
import type { SettingEditParams } from "cloudflare/resources/workers/scripts/settings";
|
|
11
12
|
import type { VersionGetResponse, VersionListResponse } from "cloudflare/resources/workers/scripts/versions";
|
|
12
|
-
import { CloudflareInvalidResponseError, cloudflareRequest, messageOf } from "../client/errors";
|
|
13
|
+
import { CloudflareInvalidResponseError, cloudflareRequest, isNotFoundError, messageOf } from "../client/errors";
|
|
13
14
|
import { CloudflareManager } from "../client/manager";
|
|
14
15
|
|
|
15
16
|
/** Per-call SDK timeout + retry budget for Worker management operations. */
|
|
@@ -212,6 +213,45 @@ export class CloudflareWorkersManager extends CloudflareManager {
|
|
|
212
213
|
);
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A deployed Worker's **plain-text vars**, keyed by binding name — or `null` when the account holds
|
|
218
|
+
* no Worker of that name.
|
|
219
|
+
*
|
|
220
|
+
* Read through `scripts/script-and-version-settings`, which answers the *deployed* script's binding
|
|
221
|
+
* list in one call. {@link listVersions} + {@link getVersion} would answer the same question in two,
|
|
222
|
+
* off the newest *uploaded* version — which is not necessarily the deployed one under gradual
|
|
223
|
+
* rollouts, and which costs a round trip to learn a version id nobody wanted. So: settings.
|
|
224
|
+
*
|
|
225
|
+
* **Only `plain_text`.** A `secret_text` binding's value is never returned by the API, and a caller
|
|
226
|
+
* that wanted one would be asking the wrong question anyway; every other binding kind is a resource
|
|
227
|
+
* reference rather than a value. So this is the vars block a `wrangler.jsonc` declares, read back.
|
|
228
|
+
*
|
|
229
|
+
* `null` for a missing Worker rather than a throw, because "there is no such Worker" is an ordinary
|
|
230
|
+
* answer to this question — it is what a first deploy looks like. Every other failure throws, so a
|
|
231
|
+
* caller can tell *not deployed* from *the account would not answer*: {@link CloudflareWorkersManager}
|
|
232
|
+
* has no business collapsing those two into one silence, and #537's gate reports them differently.
|
|
233
|
+
*/
|
|
234
|
+
async getWorkerVars(scriptName: string): Promise<Record<string, string> | null> {
|
|
235
|
+
return cloudflareRequest(`get vars for '${scriptName}'`, async () => {
|
|
236
|
+
let settings: ScriptAndVersionSettingGetResponse;
|
|
237
|
+
try {
|
|
238
|
+
settings = await this.getClient().workers.scripts.scriptAndVersionSettings.get(
|
|
239
|
+
scriptName,
|
|
240
|
+
{ account_id: this.accountId },
|
|
241
|
+
requestOptions,
|
|
242
|
+
);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (isNotFoundError(error)) return null;
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
const vars: Record<string, string> = {};
|
|
248
|
+
for (const binding of settings.bindings ?? []) {
|
|
249
|
+
if (binding.type === "plain_text") vars[binding.name] = binding.text;
|
|
250
|
+
}
|
|
251
|
+
return vars;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
215
255
|
/** Edit a Worker's settings (observability, logpush, tags, …). */
|
|
216
256
|
async updateSettings(scriptName: string, settings: WorkerSettings): Promise<void> {
|
|
217
257
|
await cloudflareRequest(`update settings for '${scriptName}'`, () =>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import { guaranteedErrorParams } from "@pithy-sh/core/src/error/messageParams";
|
|
4
5
|
import { ErrorPayload, kitErrorStatus } from "@pithy-sh/core/src/error/payload";
|
|
5
6
|
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
8
|
import {
|
|
7
9
|
decodeWorkflowStepMessage,
|
|
8
10
|
MAX_WORKFLOW_STEP_TEXT,
|
|
@@ -250,6 +252,34 @@ export const WORKFLOW_FAILED_STATUS = 500 as const;
|
|
|
250
252
|
* the boundary — and nothing derived from the step's own `detail` is in it, because the step's
|
|
251
253
|
* `detail` never left the step. The only fields promoted from the far side are `message` and `action`,
|
|
252
254
|
* both already public, both already proved kit-authored by `kitSentence`.
|
|
255
|
+
*
|
|
256
|
+
* ## `params` did not cross either, and a recovered code still has to keep their promise
|
|
257
|
+
*
|
|
258
|
+
* `core`'s `GUARANTEED_ERROR_PARAMS` states the names a code's throw sites pass on **every** path, and
|
|
259
|
+
* a locale writes `{apiAnswer}` into its sentence against that statement. Re-raising a recovered code
|
|
260
|
+
* here is a throw site like any other — it just happens to be one whose inputs came over a wire that
|
|
261
|
+
* carries three fields. So every guaranteed name is supplied, as the empty string: `interpolate` leaves
|
|
262
|
+
* an unsupplied placeholder written out, and `{apiAnswer}` on a Spanish reader's screen is the exact
|
|
263
|
+
* defect the declaration exists to prevent. Empty is also the honest value — it is what the catalogs
|
|
264
|
+
* define as *no answer to quote*, and the answer genuinely did not cross.
|
|
265
|
+
*
|
|
266
|
+
* **One reader does lose something by it, and it is worth naming rather than glossing.** An earlier
|
|
267
|
+
* version of this comment said nothing was lost, on the grounds that the API's words are already inside
|
|
268
|
+
* the promoted English `message`. That is true of the operator's terminal, the `--json` line and any
|
|
269
|
+
* client with no catalog — but the contract is `t.maybe(payload.code, payload.params) ?? payload.message`
|
|
270
|
+
* (`core/src/i18n/translator.ts`), and `maybe` returns null only on a catalog **miss**. `es` has an entry
|
|
271
|
+
* for this code, so a Spanish reader gets the translated sentence with an empty `{apiAnswer}` rather
|
|
272
|
+
* than falling through to the English one that carries the answer:
|
|
273
|
+
*
|
|
274
|
+
* ```
|
|
275
|
+
* at the CLI No se ha podido completar la llamada a Cloudflare: 10000 Authentication error.
|
|
276
|
+
* across a step No se ha podido completar la llamada a Cloudflare.
|
|
277
|
+
* ```
|
|
278
|
+
*
|
|
279
|
+
* The step channel carries three fields and `params` is not one of them, so closing this means widening
|
|
280
|
+
* the channel or re-parsing the promoted sentence — the first is a change to a durable format, the
|
|
281
|
+
* second reconstructs data from prose. Neither belongs in a re-raise, so the gap stands and is written
|
|
282
|
+
* down here instead of being claimed away.
|
|
253
283
|
*/
|
|
254
284
|
export function terminalWorkflowError(args: {
|
|
255
285
|
/** What the instance's steps said, or `null` when no step reported a failure. */
|
|
@@ -267,7 +297,7 @@ export function terminalWorkflowError(args: {
|
|
|
267
297
|
|
|
268
298
|
const status = failure?.code === undefined ? undefined : kitErrorStatus(failure.code);
|
|
269
299
|
if (failure?.sentence !== undefined && failure.code !== undefined && status !== undefined) {
|
|
270
|
-
const candidate = { code: failure.code, status, message, action, detail };
|
|
300
|
+
const candidate = { code: failure.code, status, message, action, detail, params: crossedParams(failure.code) };
|
|
271
301
|
// Parsed rather than trusted. A recovered code is a string from a Worker we did not write, and one
|
|
272
302
|
// kit member (`validation/invalid_input`) requires a field this boundary has no way to supply — so
|
|
273
303
|
// a payload that would not validate must not be thrown from the error path. It falls through to
|
|
@@ -278,3 +308,16 @@ export function terminalWorkflowError(args: {
|
|
|
278
308
|
|
|
279
309
|
return new PithyError({ code: WORKFLOW_FAILED_CODE, status: WORKFLOW_FAILED_STATUS, message, action, detail });
|
|
280
310
|
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The `params` a re-raised code owes a translating client: every name its throw sites guarantee, empty.
|
|
314
|
+
*
|
|
315
|
+
* `undefined` when the code guarantees none, so a payload for the other 60-odd codes is byte-identical
|
|
316
|
+
* to what it was — an empty `params` object on every recovered error would be a new field on the wire
|
|
317
|
+
* for no reader.
|
|
318
|
+
*/
|
|
319
|
+
function crossedParams(code: string): MessageParams | undefined {
|
|
320
|
+
const names = guaranteedErrorParams(code);
|
|
321
|
+
if (names.length === 0) return undefined;
|
|
322
|
+
return Object.fromEntries(names.map((name) => [name, ""]));
|
|
323
|
+
}
|