@pithy-sh/core 0.11.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/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/capability.d.ts +23 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/purge.d.ts +122 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +131 -0
- package/dist/cli/commandCatalog.generated.d.ts +54 -2
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +61 -0
- package/dist/cli/commandFacts.d.ts +1 -0
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +7 -0
- package/dist/controlPlane/data/connection.d.ts +2 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +1 -1
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +40 -1
- package/dist/controlPlane/http/handlers.d.ts +79 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +37 -3
- package/dist/controlPlane/http/responses.d.ts +98 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +41 -1
- package/dist/controlPlane/http/routes.d.ts +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -3
- package/dist/controlPlane/scope/scope.d.ts +13 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -4
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +2 -0
- package/dist/createEntrypoint.d.ts +7 -1
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +15 -5
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +72 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +144 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +48 -0
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +42 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- 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/audit/recorder.ts +10 -2
- package/src/capability/capability.ts +26 -0
- package/src/capability/purge.ts +187 -0
- package/src/cli/commandCatalog.generated.ts +65 -0
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +11 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/guard.ts +60 -1
- package/src/controlPlane/http/handlers.ts +57 -4
- package/src/controlPlane/http/responses.ts +100 -0
- package/src/controlPlane/http/routes.ts +31 -1
- package/src/controlPlane/scope/scope.ts +14 -3
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +4 -0
- package/src/createEntrypoint.ts +17 -2
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +110 -0
- package/src/error/pithyError.ts +21 -7
- package/src/http/validation.ts +35 -3
- package/src/index.ts +1 -0
- package/src/logger/logger.ts +12 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/version.generated.ts +1 -1
package/src/data/codecs.ts
CHANGED
|
@@ -85,46 +85,78 @@ export const JsonDate = z.codec(z.union([z.string(), z.number(), z.date()]), z.d
|
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* sqliteJson:
|
|
89
|
-
*
|
|
90
|
-
* against `schema` before it reaches the app.
|
|
88
|
+
* sqliteJson's decode: a JSON string is parsed, an already-parsed value passes through. One function for every
|
|
89
|
+
* column, so `isSqliteJson` can tell the kit's codec from any other by its decode.
|
|
91
90
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* text an older writer put there, or a payload a customer's Worker returned, can be.
|
|
95
|
-
* `JSON.stringify` throws a `TypeError` on a `BigInt` and on a cycle, either of
|
|
96
|
-
* which a `schema` can legitimately admit. Both become an issue (#358).
|
|
91
|
+
* **`JSON.parse` throws**, and it was reachable: a column holding text an older writer put there, or a payload a
|
|
92
|
+
* customer's Worker returned, need not be a JSON document. That becomes an issue (#358).
|
|
97
93
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
94
|
+
function decodeJson(value: unknown, payload: Payload<unknown>): unknown {
|
|
95
|
+
if (typeof value !== "string") return value;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(value);
|
|
98
|
+
} catch {
|
|
99
|
+
payload.issues.push({ code: "invalid_format", format: "json", input: value, message: "Not valid JSON." });
|
|
100
|
+
return z.NEVER;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* sqliteJson's encode. **`JSON.stringify` throws** on a `BigInt` and on a cycle, either of which a schema can
|
|
106
|
+
* legitimately admit, so that becomes an issue too (#358).
|
|
107
|
+
*/
|
|
108
|
+
function encodeJson(value: unknown, payload: Payload<unknown>): string {
|
|
109
|
+
// The offending value is deliberately not carried onto the issue: it is the thing JSON could not hold, so a
|
|
110
|
+
// reporter that renders an issue would be the second place to trip over it.
|
|
111
|
+
try {
|
|
112
|
+
const json = JSON.stringify(value);
|
|
113
|
+
if (typeof json === "string") return json;
|
|
114
|
+
} catch {
|
|
115
|
+
// Falls through to the one push below. A `BigInt`, a cycle and an `undefined` result are the same answer —
|
|
116
|
+
// this value does not survive the round trip — and they deserve the same issue.
|
|
117
|
+
}
|
|
118
|
+
payload.issues.push({
|
|
119
|
+
code: "invalid_format",
|
|
120
|
+
format: "json",
|
|
121
|
+
input: undefined,
|
|
122
|
+
message: "Not serializable as JSON.",
|
|
127
123
|
});
|
|
124
|
+
return z.NEVER;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* sqliteJson: T ↔ JSON string, validated by `schema` on both sides. Decode accepts a JSON string or an
|
|
129
|
+
* already-parsed value; the result is validated against `schema` before it reaches the app.
|
|
130
|
+
*/
|
|
131
|
+
export function sqliteJson<T extends z.ZodType>(schema: T): SqliteJson<T> {
|
|
132
|
+
return z.codec(z.union([z.string(), schema]), schema, {
|
|
133
|
+
decode: decodeJson as (value: string | z.output<T>, payload: Payload<string | z.output<T>>) => z.input<T>,
|
|
134
|
+
encode: encodeJson as (value: z.input<T>, payload: Payload<z.input<T>>) => string,
|
|
135
|
+
}) as SqliteJson<T>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Names the type `sqliteJson` returns, and nothing else. A type only: no value carries it. `Redacted<T>` reads a JSON
|
|
140
|
+
* column's fields through it, as `redact` reads them through {@link isSqliteJson}, so an adopter's codec of the same
|
|
141
|
+
* shape is opaque to both.
|
|
142
|
+
*/
|
|
143
|
+
declare const kitJsonColumn: unique symbol;
|
|
144
|
+
|
|
145
|
+
/** What `sqliteJson(schema)` returns: the kit's JSON column over `schema`. Clones keep it, as they return `this`. */
|
|
146
|
+
export type SqliteJson<T extends z.ZodType> = z.ZodCodec<z.ZodUnion<[z.ZodString, T]>, T> & {
|
|
147
|
+
readonly [kitJsonColumn]: T;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** The schema a kit JSON column decodes to, or `never` for any other node. */
|
|
151
|
+
export type SqliteJsonSchema<S> = S extends { readonly [kitJsonColumn]: infer T } ? T : never;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a schema is a JSON column `sqliteJson` built, or a clone of one (`.describe()`, `.meta()`), read by its
|
|
155
|
+
* decode. `redact` walks such a column's fields one by one, and treats every other codec as opaque.
|
|
156
|
+
*/
|
|
157
|
+
export function isSqliteJson(schema: z.ZodType): boolean {
|
|
158
|
+
const def = schema._zod.def as { type: string; transform?: unknown };
|
|
159
|
+
return def.type === "pipe" && def.transform === decodeJson;
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
export const MAX_TIMEZONE_LENGTH = 64;
|
package/src/error/extend.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import type { MessageParams } from "../i18n/catalog";
|
|
5
|
+
import type { UnmarkedString } from "../privacy/types";
|
|
5
6
|
import type { ErrorPayload, KitErrorDomain } from "./payload";
|
|
6
7
|
import { ExtendedErrorPayload } from "./payload";
|
|
7
8
|
import { InternalError } from "./pithyError";
|
|
@@ -95,11 +96,11 @@ export function defineErrorPayload<const Code extends `${string}/${string}`>(
|
|
|
95
96
|
/** The HTTP status this code maps to. A 4xx or a 5xx — an error is not a success. */
|
|
96
97
|
status: number;
|
|
97
98
|
/** Public, safe-to-expose summary. Sent to clients and shown in the terminal. */
|
|
98
|
-
message:
|
|
99
|
-
/** Operator remediation hint. Becomes the CLI action line. Never sent to a client. */
|
|
100
|
-
action?:
|
|
101
|
-
/** Internal context for logs + audit. NEVER serialized to clients — the HTTP codec strips it. */
|
|
102
|
-
detail?:
|
|
99
|
+
message: UnmarkedString;
|
|
100
|
+
/** Operator remediation hint. Becomes the CLI action line. Never sent to a client. Never a marked value (#132). */
|
|
101
|
+
action?: UnmarkedString;
|
|
102
|
+
/** Internal context for logs + audit. NEVER serialized to clients — the HTTP codec strips it. Never a marked value (#132). */
|
|
103
|
+
detail?: UnmarkedString;
|
|
103
104
|
/**
|
|
104
105
|
* Values a translating client interpolates into its own wording for this code. Client-facing:
|
|
105
106
|
* these cross the boundary with `message`, unlike `action` and `detail`.
|
package/src/error/payload.ts
CHANGED
|
@@ -181,6 +181,38 @@ const AuthSessionNotFreshPublic = z
|
|
|
181
181
|
})
|
|
182
182
|
.describe("The session is authentic but the holder authenticated too long ago to attach a provider (403).");
|
|
183
183
|
|
|
184
|
+
const AuthAccountSuspendedPublic = z
|
|
185
|
+
.object({
|
|
186
|
+
code: z
|
|
187
|
+
.literal("auth/account_suspended")
|
|
188
|
+
.describe(
|
|
189
|
+
"The account this credential belongs to is suspended, so the request is refused whatever it carries. An operator suspended it on purpose, with a reason, and the reason is the `message`. When the suspension has an end, `params.suspendedUntil` names it as ISO-8601; a suspension past its end is no longer one, and nothing has to clear it. A separate code from `auth/forbidden` so a client can render the screen that says what happened rather than a generic refusal.",
|
|
190
|
+
),
|
|
191
|
+
status: z
|
|
192
|
+
.literal(403)
|
|
193
|
+
.describe(
|
|
194
|
+
"Forbidden — the credential is genuine and the account behind it may not act. Not 401: signing in again cannot help, and a client that read it as an expired token would loop.",
|
|
195
|
+
),
|
|
196
|
+
...publicFields,
|
|
197
|
+
})
|
|
198
|
+
.describe("The account is suspended (403).");
|
|
199
|
+
|
|
200
|
+
const AuthAccountDeletedPublic = z
|
|
201
|
+
.object({
|
|
202
|
+
code: z
|
|
203
|
+
.literal("auth/account_deleted")
|
|
204
|
+
.describe(
|
|
205
|
+
"The account this credential belongs to is deleted and inside its purge window: it cannot sign in or act, and it can still be restored until the window closes. After the purge there is no account, and the credential is simply invalid.",
|
|
206
|
+
),
|
|
207
|
+
status: z
|
|
208
|
+
.literal(403)
|
|
209
|
+
.describe(
|
|
210
|
+
"Forbidden — the account exists and may not act. Not 404, because until the purge runs it can come back, and not 401, because signing in again cannot help.",
|
|
211
|
+
),
|
|
212
|
+
...publicFields,
|
|
213
|
+
})
|
|
214
|
+
.describe("The account is deleted and awaiting its purge (403).");
|
|
215
|
+
|
|
184
216
|
const NotFoundPublic = z
|
|
185
217
|
.object({
|
|
186
218
|
code: z.literal("core/not_found").describe("The requested resource does not exist."),
|
|
@@ -1493,6 +1525,54 @@ const OrganizationNominationInvalidPublic = z
|
|
|
1493
1525
|
})
|
|
1494
1526
|
.describe("No standing ownership offer this caller may accept (400).");
|
|
1495
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
|
+
|
|
1496
1576
|
// --- @pithy-sh/artifacts: Git repositories, ownership, grants and short-lived tokens ---
|
|
1497
1577
|
|
|
1498
1578
|
const ArtifactsNotFoundPublic = z
|
|
@@ -1758,6 +1838,8 @@ export const KitPublicErrorPayload = z
|
|
|
1758
1838
|
ForbiddenPublic,
|
|
1759
1839
|
AuthProviderUnavailablePublic,
|
|
1760
1840
|
AuthSessionNotFreshPublic,
|
|
1841
|
+
AuthAccountSuspendedPublic,
|
|
1842
|
+
AuthAccountDeletedPublic,
|
|
1761
1843
|
NotFoundPublic,
|
|
1762
1844
|
ConflictPublic,
|
|
1763
1845
|
RateLimitPublic,
|
|
@@ -1874,6 +1956,10 @@ export const KitPublicErrorPayload = z
|
|
|
1874
1956
|
OrganizationLastAdministratorPublic,
|
|
1875
1957
|
OrganizationSlugTakenPublic,
|
|
1876
1958
|
OrganizationNominationInvalidPublic,
|
|
1959
|
+
OrganizationLastOwnerPublic,
|
|
1960
|
+
OrganizationAlreadyMemberPublic,
|
|
1961
|
+
OrganizationSeatLimitReachedPublic,
|
|
1962
|
+
OrganizationSuspendedPublic,
|
|
1877
1963
|
ArtifactsNotFoundPublic,
|
|
1878
1964
|
ArtifactsForbiddenPublic,
|
|
1879
1965
|
ArtifactsInvalidNamePublic,
|
|
@@ -1914,6 +2000,12 @@ const AuthProviderUnavailable = AuthProviderUnavailablePublic.extend(internalFie
|
|
|
1914
2000
|
const AuthSessionNotFresh = AuthSessionNotFreshPublic.extend(internalFields).describe(
|
|
1915
2001
|
AuthSessionNotFreshPublic.description ?? "",
|
|
1916
2002
|
);
|
|
2003
|
+
const AuthAccountSuspended = AuthAccountSuspendedPublic.extend(internalFields).describe(
|
|
2004
|
+
AuthAccountSuspendedPublic.description ?? "",
|
|
2005
|
+
);
|
|
2006
|
+
const AuthAccountDeleted = AuthAccountDeletedPublic.extend(internalFields).describe(
|
|
2007
|
+
AuthAccountDeletedPublic.description ?? "",
|
|
2008
|
+
);
|
|
1917
2009
|
const NotFound = NotFoundPublic.extend(internalFields).describe(NotFoundPublic.description ?? "");
|
|
1918
2010
|
const Conflict = ConflictPublic.extend(internalFields).describe(ConflictPublic.description ?? "");
|
|
1919
2011
|
const RateLimit = RateLimitPublic.extend(internalFields).describe(RateLimitPublic.description ?? "");
|
|
@@ -2264,6 +2356,18 @@ const OrganizationSlugTaken = OrganizationSlugTakenPublic.extend(internalFields)
|
|
|
2264
2356
|
const OrganizationNominationInvalid = OrganizationNominationInvalidPublic.extend(internalFields).describe(
|
|
2265
2357
|
OrganizationNominationInvalidPublic.description ?? "",
|
|
2266
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
|
+
);
|
|
2267
2371
|
|
|
2268
2372
|
const ArtifactsNotFound = ArtifactsNotFoundPublic.extend(internalFields).describe(
|
|
2269
2373
|
ArtifactsNotFoundPublic.description ?? "",
|
|
@@ -2311,6 +2415,8 @@ export const KitErrorPayload = z
|
|
|
2311
2415
|
Forbidden,
|
|
2312
2416
|
AuthProviderUnavailable,
|
|
2313
2417
|
AuthSessionNotFresh,
|
|
2418
|
+
AuthAccountSuspended,
|
|
2419
|
+
AuthAccountDeleted,
|
|
2314
2420
|
NotFound,
|
|
2315
2421
|
Conflict,
|
|
2316
2422
|
RateLimit,
|
|
@@ -2427,6 +2533,10 @@ export const KitErrorPayload = z
|
|
|
2427
2533
|
OrganizationLastAdministrator,
|
|
2428
2534
|
OrganizationSlugTaken,
|
|
2429
2535
|
OrganizationNominationInvalid,
|
|
2536
|
+
OrganizationLastOwner,
|
|
2537
|
+
OrganizationAlreadyMember,
|
|
2538
|
+
OrganizationSeatLimitReached,
|
|
2539
|
+
OrganizationSuspended,
|
|
2430
2540
|
ArtifactsNotFound,
|
|
2431
2541
|
ArtifactsForbidden,
|
|
2432
2542
|
ArtifactsInvalidName,
|
package/src/error/pithyError.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { z } from "zod";
|
|
5
5
|
import type { MessageParams } from "../i18n/catalog";
|
|
6
|
+
import type { UnmarkedString } from "../privacy/types";
|
|
6
7
|
import type { ValidationIssue } from "./payload";
|
|
7
8
|
import { ErrorPayload } from "./payload";
|
|
8
9
|
|
|
@@ -18,7 +19,20 @@ import { ErrorPayload } from "./payload";
|
|
|
18
19
|
export class PithyError extends Error {
|
|
19
20
|
readonly payload: ErrorPayload;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `message`, `action` and `detail` are each an {@link UnmarkedString} here and on every subclass's arguments
|
|
24
|
+
* (#132): a value marked with a privacy class is refused at compile time, because all three ride into every log
|
|
25
|
+
* line and audit row that carries this error. A template literal is a plain `string`, so interpolation is not caught — see
|
|
26
|
+
* `privacy/types.ts` for exactly what is.
|
|
27
|
+
*/
|
|
28
|
+
constructor(
|
|
29
|
+
payload: ErrorPayload & {
|
|
30
|
+
message: UnmarkedString;
|
|
31
|
+
action?: UnmarkedString | undefined;
|
|
32
|
+
detail?: UnmarkedString | undefined;
|
|
33
|
+
},
|
|
34
|
+
options?: { cause?: unknown },
|
|
35
|
+
) {
|
|
22
36
|
const parsed = ErrorPayload.parse(payload);
|
|
23
37
|
super(parsed.message, options);
|
|
24
38
|
this.payload = parsed;
|
|
@@ -28,12 +42,12 @@ export class PithyError extends Error {
|
|
|
28
42
|
|
|
29
43
|
/** Variable parts every subclass accepts; the `code`/`status` are fixed by the subclass. */
|
|
30
44
|
interface ErrorArgs {
|
|
31
|
-
/** Override the public, safe-to-expose message. Defaults to a per-code summary. */
|
|
32
|
-
message?:
|
|
33
|
-
/** A remediation hint (CLI action line). */
|
|
34
|
-
action?:
|
|
35
|
-
/** Internal context for logs + audit. Never serialized to clients. */
|
|
36
|
-
detail?:
|
|
45
|
+
/** Override the public, safe-to-expose message. Defaults to a per-code summary. Never a privacy-marked value. */
|
|
46
|
+
message?: UnmarkedString;
|
|
47
|
+
/** A remediation hint (CLI action line). Never a privacy-marked value. */
|
|
48
|
+
action?: UnmarkedString;
|
|
49
|
+
/** Internal context for logs + audit. Never serialized to clients, and never a privacy-marked value (#132). */
|
|
50
|
+
detail?: UnmarkedString;
|
|
37
51
|
/**
|
|
38
52
|
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
39
53
|
* unlike `action` and `detail` — these cross the boundary with `message`. The `code` is the catalog
|
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/index.ts
CHANGED
|
@@ -102,6 +102,7 @@ export {
|
|
|
102
102
|
KEYS_ROTATE_SCOPE,
|
|
103
103
|
MANIFEST_READ_SCOPE,
|
|
104
104
|
MIGRATIONS_READ_SCOPE,
|
|
105
|
+
PRIVACY_READ_SCOPE,
|
|
105
106
|
SEAM_SCOPES,
|
|
106
107
|
} from "./controlPlane/scope/scope";
|
|
107
108
|
export { exportPublicJwk, type MintControlPlaneToken, mintControlPlaneToken } from "./controlPlane/token/mint";
|
package/src/logger/logger.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { ErrorPayload } from "../error/payload";
|
|
5
5
|
import { PithyError } from "../error/pithyError";
|
|
6
|
+
import type { Loggable, UnmarkedString } from "../privacy/types";
|
|
6
7
|
import { LOG_LEVEL_ORDER, type LogFields, type LogLevel, type LogRecord } from "./record";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,22 +16,28 @@ import { LOG_LEVEL_ORDER, type LogFields, type LogLevel, type LogRecord } from "
|
|
|
15
16
|
* The reserved `error` field on any call may carry a `PithyError` or `ErrorPayload` — the engine lifts
|
|
16
17
|
* it into the record's `error`, `detail` and all. A log is internal; this is the inverse of the HTTP
|
|
17
18
|
* codec, which strips `detail`. The logger must never be wired to a client-facing surface.
|
|
19
|
+
*
|
|
20
|
+
* **A value marked with a privacy class does not compile here** (#132). Every method takes `F & Loggable<F>`,
|
|
21
|
+
* so a field holding a `pii()`/`phi()`/`secret()`/`pci()` value — the value itself, or one inside an object or
|
|
22
|
+
* an array, to the depth `privacy/types.ts` states — is refused at that key, and `redact(Schema, value)` is the
|
|
23
|
+
* way through. The message and a child's name take `UnmarkedString`, so `log.info(user.email)` is refused too. `LogFields` the schema stays permissive: a record that reaches a sink at runtime still parses,
|
|
24
|
+
* because a brand is a type and a parsed log line has none.
|
|
18
25
|
*/
|
|
19
26
|
export interface Logger {
|
|
20
27
|
/** Dev-time diagnostics. Dropped unless the logger's level is `debug`. */
|
|
21
|
-
debug(msg:
|
|
28
|
+
debug<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
22
29
|
/** Normal operational events. */
|
|
23
|
-
info(msg:
|
|
30
|
+
info<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
24
31
|
/** A recoverable problem worth attention. */
|
|
25
|
-
warn(msg:
|
|
32
|
+
warn<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
26
33
|
/** A failure that was observed. Pass `{ error }` (a PithyError) to carry its full payload. */
|
|
27
|
-
error(msg:
|
|
34
|
+
error<F extends LogFields = LogFields>(msg: UnmarkedString, fields?: F & Loggable<F>): void;
|
|
28
35
|
/**
|
|
29
36
|
* A namespaced sub-logger. `name` composes as `parent:child`; bound `fields` merge into every record
|
|
30
37
|
* the child emits. Omit `name` (pass `undefined`) to bind fields only — how request-correlation
|
|
31
38
|
* attaches `request`/`method`/`path` without renaming the logger.
|
|
32
39
|
*/
|
|
33
|
-
child(name?:
|
|
40
|
+
child<F extends LogFields = LogFields>(name?: UnmarkedString, fields?: F & Loggable<F>): Logger;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/** A sink receives a finished {@link LogRecord}. Adapters supply one; the engine builds records and calls it. */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type EncryptionConfig, keyBytes } from "../crypto/envelope";
|
|
5
|
+
import { FIELD_KEYS_BINDING } from "../crypto/fieldKeys";
|
|
6
|
+
import { type BlindIndexNormalizer, normalizeForIndex } from "./encrypted";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **A blind index: a keyed hash an encrypted column can be found by** (#390).
|
|
10
|
+
*
|
|
11
|
+
* An encrypted column cannot answer `WHERE email = ?`. A sibling column holding `HMAC(key, normalize(value))` can,
|
|
12
|
+
* for equality and nothing else. One rule here is shared by the app Worker that seals, `sealed().match` that looks
|
|
13
|
+
* up, and the rotator that re-indexes inside the manager, so the three cannot hash one value two ways.
|
|
14
|
+
*
|
|
15
|
+
* - **The key is derived, on the field key's own version axis.** Each version's index key is HKDF-SHA256 over that
|
|
16
|
+
* version's field key, with the label `pithy/blind-index/<normalizer>`. One secret, one rotation.
|
|
17
|
+
* - **The value carries its version**: `<version>:<hmac>`, so the stale count reaches an index exactly as it reaches
|
|
18
|
+
* an envelope, by reading the prefix.
|
|
19
|
+
* - **The normalizer is in the label**, so changing a field's normalizer cannot silently miss against old hashes: they
|
|
20
|
+
* are hashes under a different key, and the change reads as a re-index.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The HKDF label one normalizer's index keys are derived under. */
|
|
24
|
+
export function blindIndexLabel(normalizer: BlindIndexNormalizer): string {
|
|
25
|
+
return `pithy/blind-index/${normalizer}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Unpadded base64url, the alphabet a blind index is written in. */
|
|
29
|
+
function base64Url(bytes: Uint8Array): string {
|
|
30
|
+
let binary = "";
|
|
31
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
32
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The HMAC key for one version and normalizer: HKDF-SHA256 over that version's field key. */
|
|
36
|
+
export async function blindIndexKey(
|
|
37
|
+
config: EncryptionConfig,
|
|
38
|
+
version: number,
|
|
39
|
+
normalizer: BlindIndexNormalizer,
|
|
40
|
+
): Promise<CryptoKey> {
|
|
41
|
+
const material = await crypto.subtle.importKey(
|
|
42
|
+
"raw",
|
|
43
|
+
keyBytes(config, version, { keys: FIELD_KEYS_BINDING }),
|
|
44
|
+
"HKDF",
|
|
45
|
+
false,
|
|
46
|
+
["deriveBits"],
|
|
47
|
+
);
|
|
48
|
+
const bits = await crypto.subtle.deriveBits(
|
|
49
|
+
{
|
|
50
|
+
name: "HKDF",
|
|
51
|
+
hash: "SHA-256",
|
|
52
|
+
salt: new Uint8Array(0),
|
|
53
|
+
info: Uint8Array.from(new TextEncoder().encode(blindIndexLabel(normalizer))),
|
|
54
|
+
},
|
|
55
|
+
material,
|
|
56
|
+
256,
|
|
57
|
+
);
|
|
58
|
+
return crypto.subtle.importKey("raw", bits, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The blind index of `value` under `version`: `<version>:<hmac>`. The value is normalized first. */
|
|
62
|
+
export async function blindIndexValue(
|
|
63
|
+
config: EncryptionConfig,
|
|
64
|
+
version: number,
|
|
65
|
+
normalizer: BlindIndexNormalizer,
|
|
66
|
+
value: string,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const key = await blindIndexKey(config, version, normalizer);
|
|
69
|
+
const mac = await crypto.subtle.sign(
|
|
70
|
+
"HMAC",
|
|
71
|
+
key,
|
|
72
|
+
Uint8Array.from(new TextEncoder().encode(normalizeForIndex(normalizer, value))),
|
|
73
|
+
);
|
|
74
|
+
return `${version}:${base64Url(new Uint8Array(mac))}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The version a blind-index value was written under, or `null` for a value that is not one. */
|
|
78
|
+
export function blindIndexVersion(value: string): number | null {
|
|
79
|
+
const match = /^(\d+):/.exec(value);
|
|
80
|
+
return match ? Number(match[1]) : null;
|
|
81
|
+
}
|