@pithy-sh/core 0.12.0 → 0.13.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/audit/composed.d.ts +18 -0
- package/dist/audit/composed.d.ts.map +1 -0
- package/dist/audit/composed.js +28 -0
- package/dist/capability/capability.d.ts +11 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/purge.d.ts +43 -1
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +58 -3
- package/dist/cli/commandCatalog.generated.d.ts +1 -1
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +6 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +40 -1
- package/dist/createEntrypoint.d.ts +7 -1
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +15 -5
- package/dist/error/http.d.ts +48 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +96 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +32 -0
- package/dist/http/validation.d.ts +11 -2
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +20 -3
- package/dist/logger/record.d.ts +28 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/src/audit/composed.ts +30 -0
- package/src/capability/capability.ts +11 -0
- package/src/capability/purge.ts +66 -3
- package/src/cli/commandCatalog.generated.ts +6 -0
- package/src/controlPlane/http/guard.ts +60 -1
- package/src/createEntrypoint.ts +17 -2
- package/src/error/payload.ts +68 -0
- package/src/http/validation.ts +35 -3
- package/src/version.generated.ts +1 -1
package/src/createEntrypoint.ts
CHANGED
|
@@ -28,7 +28,13 @@ export interface PithyEntrypoint {
|
|
|
28
28
|
*/
|
|
29
29
|
email?: (message: ForwardableEmailMessage, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
|
|
30
30
|
/**
|
|
31
|
-
* The Worker `scheduled` handler — starts every registered job
|
|
31
|
+
* The Worker `scheduled` handler — starts every registered job whose `schedule` is the cron that fired.
|
|
32
|
+
*
|
|
33
|
+
* A Worker carries one `scheduled` entry for all of its `triggers.crons`, and the runtime names the one
|
|
34
|
+
* that fired on `controller.cron`, verbatim as `wrangler.jsonc` declares it. Only the jobs whose
|
|
35
|
+
* `schedule` is that string start: a daily job composed beside an hourly one must not run every hour,
|
|
36
|
+
* and the hourly one must not run an extra time at the daily job's minute (#622). An event that names
|
|
37
|
+
* no cron, or one no job declares, starts nothing.
|
|
32
38
|
*
|
|
33
39
|
* **Present only when at least one job carries a cron.** The reason differs from `email`'s: an
|
|
34
40
|
* inert `email()` silently *drops* mail, which is a correctness bug, whereas an inert `scheduled()`
|
|
@@ -50,6 +56,13 @@ export interface PithyEntrypoint {
|
|
|
50
56
|
scheduled?: (controller: unknown, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/** The cron expression a `scheduled` event fired for, or `undefined` when the event names none. */
|
|
60
|
+
function firedCron(controller: unknown): string | undefined {
|
|
61
|
+
if (typeof controller !== "object" || controller === null) return undefined;
|
|
62
|
+
const cron = (controller as { cron?: unknown }).cron;
|
|
63
|
+
return typeof cron === "string" ? cron : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
/**
|
|
54
67
|
* The raw MIME stream of a `ForwardableEmailMessage` is **single-use** — once one handler reads it,
|
|
55
68
|
* the next sees an empty stream. When more than one capability handles inbound mail we buffer the
|
|
@@ -99,9 +112,11 @@ export function createEntrypoint<
|
|
|
99
112
|
// job whose binding is missing cannot stop the rest of the schedule from running; the failure is
|
|
100
113
|
// rethrown after the pass so the invocation is still recorded as failed.
|
|
101
114
|
if (scheduled.length > 0) {
|
|
102
|
-
entrypoint.scheduled = async (
|
|
115
|
+
entrypoint.scheduled = async (controller, env) => {
|
|
116
|
+
const cron = firedCron(controller);
|
|
103
117
|
const failures: unknown[] = [];
|
|
104
118
|
for (const entry of scheduled) {
|
|
119
|
+
if (entry.spec.schedule !== cron) continue;
|
|
105
120
|
// A cron supplies no caller input, which is an *empty* parameter object, not an absent one —
|
|
106
121
|
// `z.object({})` accepts `{}` and rejects `undefined`. A job whose schema requires fields it
|
|
107
122
|
// can never receive on a schedule therefore fails loudly here, which is the author's bug and
|
package/src/error/payload.ts
CHANGED
|
@@ -1525,6 +1525,54 @@ const OrganizationNominationInvalidPublic = z
|
|
|
1525
1525
|
})
|
|
1526
1526
|
.describe("No standing ownership offer this caller may accept (400).");
|
|
1527
1527
|
|
|
1528
|
+
const OrganizationLastOwnerPublic = z
|
|
1529
|
+
.object({
|
|
1530
|
+
code: z
|
|
1531
|
+
.literal("organization/last_owner")
|
|
1532
|
+
.describe(
|
|
1533
|
+
"An operator write would leave the account with nobody holding the role a transfer of ownership confers: removing or demoting its only owner. An account with no owner is the unadministrable state membership administration exists to get out of, so the way out is to force ownership onto somebody else first, which `params.ownershipRoute` names.",
|
|
1534
|
+
),
|
|
1535
|
+
status: z.literal(409).describe("Conflict — an account nobody owns is not a state to arrive at."),
|
|
1536
|
+
...publicFields,
|
|
1537
|
+
})
|
|
1538
|
+
.describe("The last owner cannot be removed or demoted (409).");
|
|
1539
|
+
|
|
1540
|
+
const OrganizationAlreadyMemberPublic = z
|
|
1541
|
+
.object({
|
|
1542
|
+
code: z
|
|
1543
|
+
.literal("organization/already_member")
|
|
1544
|
+
.describe(
|
|
1545
|
+
"An operator tried to add somebody who already holds a membership in the account. One membership per person per organization, so this is a refusal rather than a second row — and `params.role` names the role they already hold, because the next step is a role change rather than an add.",
|
|
1546
|
+
),
|
|
1547
|
+
status: z.literal(409).describe("Conflict."),
|
|
1548
|
+
...publicFields,
|
|
1549
|
+
})
|
|
1550
|
+
.describe("That person is already a member (409).");
|
|
1551
|
+
|
|
1552
|
+
const OrganizationSeatLimitReachedPublic = z
|
|
1553
|
+
.object({
|
|
1554
|
+
code: z
|
|
1555
|
+
.literal("organization/seat_limit_reached")
|
|
1556
|
+
.describe(
|
|
1557
|
+
"The account holds as many members as its seat limit allows, so another one would exceed it. `params.seatLimit` names the limit. An operator add may override it, and the override is recorded; an invitation accepted against a full account is refused until a seat frees.",
|
|
1558
|
+
),
|
|
1559
|
+
status: z.literal(409).describe("Conflict."),
|
|
1560
|
+
...publicFields,
|
|
1561
|
+
})
|
|
1562
|
+
.describe("The account has no free seat (409).");
|
|
1563
|
+
|
|
1564
|
+
const OrganizationSuspendedPublic = z
|
|
1565
|
+
.object({
|
|
1566
|
+
code: z
|
|
1567
|
+
.literal("organization/organization_suspended")
|
|
1568
|
+
.describe(
|
|
1569
|
+
"The organization this request acts in is suspended by an operator, so nobody can act in it until it is reinstated (#623). The caller's own account is not affected, and their other organizations are not either. `params.suspendedUntil`, when present, is when the suspension ends by itself, ISO-8601. Not `organization/forbidden`, because no role would let them, and not `organization/not_found`, because the caller is a proved member and already knows the organization exists.",
|
|
1570
|
+
),
|
|
1571
|
+
status: z.literal(403).describe("Forbidden — the organization may not be acted in, whoever asks."),
|
|
1572
|
+
...publicFields,
|
|
1573
|
+
})
|
|
1574
|
+
.describe("The organization is suspended (403).");
|
|
1575
|
+
|
|
1528
1576
|
// --- @pithy-sh/artifacts: Git repositories, ownership, grants and short-lived tokens ---
|
|
1529
1577
|
|
|
1530
1578
|
const ArtifactsNotFoundPublic = z
|
|
@@ -1908,6 +1956,10 @@ export const KitPublicErrorPayload = z
|
|
|
1908
1956
|
OrganizationLastAdministratorPublic,
|
|
1909
1957
|
OrganizationSlugTakenPublic,
|
|
1910
1958
|
OrganizationNominationInvalidPublic,
|
|
1959
|
+
OrganizationLastOwnerPublic,
|
|
1960
|
+
OrganizationAlreadyMemberPublic,
|
|
1961
|
+
OrganizationSeatLimitReachedPublic,
|
|
1962
|
+
OrganizationSuspendedPublic,
|
|
1911
1963
|
ArtifactsNotFoundPublic,
|
|
1912
1964
|
ArtifactsForbiddenPublic,
|
|
1913
1965
|
ArtifactsInvalidNamePublic,
|
|
@@ -2304,6 +2356,18 @@ const OrganizationSlugTaken = OrganizationSlugTakenPublic.extend(internalFields)
|
|
|
2304
2356
|
const OrganizationNominationInvalid = OrganizationNominationInvalidPublic.extend(internalFields).describe(
|
|
2305
2357
|
OrganizationNominationInvalidPublic.description ?? "",
|
|
2306
2358
|
);
|
|
2359
|
+
const OrganizationLastOwner = OrganizationLastOwnerPublic.extend(internalFields).describe(
|
|
2360
|
+
OrganizationLastOwnerPublic.description ?? "",
|
|
2361
|
+
);
|
|
2362
|
+
const OrganizationAlreadyMember = OrganizationAlreadyMemberPublic.extend(internalFields).describe(
|
|
2363
|
+
OrganizationAlreadyMemberPublic.description ?? "",
|
|
2364
|
+
);
|
|
2365
|
+
const OrganizationSeatLimitReached = OrganizationSeatLimitReachedPublic.extend(internalFields).describe(
|
|
2366
|
+
OrganizationSeatLimitReachedPublic.description ?? "",
|
|
2367
|
+
);
|
|
2368
|
+
const OrganizationSuspended = OrganizationSuspendedPublic.extend(internalFields).describe(
|
|
2369
|
+
OrganizationSuspendedPublic.description ?? "",
|
|
2370
|
+
);
|
|
2307
2371
|
|
|
2308
2372
|
const ArtifactsNotFound = ArtifactsNotFoundPublic.extend(internalFields).describe(
|
|
2309
2373
|
ArtifactsNotFoundPublic.description ?? "",
|
|
@@ -2469,6 +2533,10 @@ export const KitErrorPayload = z
|
|
|
2469
2533
|
OrganizationLastAdministrator,
|
|
2470
2534
|
OrganizationSlugTaken,
|
|
2471
2535
|
OrganizationNominationInvalid,
|
|
2536
|
+
OrganizationLastOwner,
|
|
2537
|
+
OrganizationAlreadyMember,
|
|
2538
|
+
OrganizationSeatLimitReached,
|
|
2539
|
+
OrganizationSuspended,
|
|
2472
2540
|
ArtifactsNotFound,
|
|
2473
2541
|
ArtifactsForbidden,
|
|
2474
2542
|
ArtifactsInvalidName,
|
package/src/http/validation.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { zValidator } from "@hono/zod-validator";
|
|
5
|
-
import type { ValidationTargets } from "hono";
|
|
5
|
+
import type { Context, MiddlewareHandler, ValidationTargets } from "hono";
|
|
6
6
|
import type { z } from "zod";
|
|
7
7
|
import { fromZodError } from "../error/pithyError";
|
|
8
8
|
|
|
@@ -70,12 +70,44 @@ export interface RequestContract {
|
|
|
70
70
|
export function validate<Target extends keyof ValidationTargets, Schema extends z.ZodType>(
|
|
71
71
|
target: Target,
|
|
72
72
|
schema: Schema,
|
|
73
|
+
onInvalid?: InvalidRequestListener,
|
|
73
74
|
) {
|
|
74
|
-
const middleware = zValidator(target, schema, validationHook);
|
|
75
75
|
const contract: RequestContract = { target, schema };
|
|
76
|
-
return Object.assign(
|
|
76
|
+
if (!onInvalid) return Object.assign(zValidator(target, schema, validationHook), { [REQUEST_CONTRACT]: contract });
|
|
77
|
+
// What each refused request sent, keyed by its context, so the listener is handed the input without
|
|
78
|
+
// reading the request raw — which no route may do.
|
|
79
|
+
const refusedInput = new WeakMap<Context, unknown>();
|
|
80
|
+
const middleware = zValidator(target, schema, (outcome: ValidationOutcome & { data?: unknown }, c: Context) => {
|
|
81
|
+
if (!outcome.success) refusedInput.set(c, outcome.data);
|
|
82
|
+
validationHook(outcome);
|
|
83
|
+
});
|
|
84
|
+
// Only a throw from the validator itself is a refusal of the request. Once `next` runs, a throw is the
|
|
85
|
+
// handler's, and is the handler's to record.
|
|
86
|
+
const listened: MiddlewareHandler = async (c, next) => {
|
|
87
|
+
let reached = false;
|
|
88
|
+
try {
|
|
89
|
+
return await middleware(c, async () => {
|
|
90
|
+
reached = true;
|
|
91
|
+
await next();
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!reached) await onInvalid(c, error, refusedInput.get(c));
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return Object.assign(listened as typeof middleware, { [REQUEST_CONTRACT]: contract });
|
|
77
99
|
}
|
|
78
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Told when {@link validate} refuses a request — a body that does not parse, or one that fails the schema —
|
|
103
|
+
* before the refusal is thrown. A route whose every attempt must be on the audit trail records the refusal
|
|
104
|
+
* here. It cannot turn the refusal into anything else: the error is thrown after it returns, and a listener
|
|
105
|
+
* that throws replaces it. `input` is what the request sent for the validated part, as it arrived; it is
|
|
106
|
+
* `undefined` when there was nothing to read, as for a body that is not JSON. Input validated by an earlier
|
|
107
|
+
* {@link validate} on the same route is readable with `c.req.valid(target)`.
|
|
108
|
+
*/
|
|
109
|
+
export type InvalidRequestListener = (c: Context, error: unknown, input: unknown) => Promise<void> | void;
|
|
110
|
+
|
|
79
111
|
/** The contract a {@link validate} middleware carries, or `undefined` for any other handler. */
|
|
80
112
|
export function requestContractOf(handler: unknown): RequestContract | undefined {
|
|
81
113
|
if (typeof handler !== "function") return undefined;
|
package/src/version.generated.ts
CHANGED