@pithy-sh/core 0.13.0 → 0.15.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/README.md +54 -0
- package/dist/audit/auditEvent.js +1 -1
- package/dist/audit/chain.d.ts +289 -0
- package/dist/audit/chain.d.ts.map +1 -0
- package/dist/audit/chain.js +400 -0
- package/dist/audit/citations.d.ts +52 -0
- package/dist/audit/citations.d.ts.map +1 -0
- package/dist/audit/citations.js +13 -0
- package/dist/capability/bindings.d.ts +22 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +32 -2
- package/dist/capability/capability.d.ts +123 -16
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +17 -2
- package/dist/capability/erasure.d.ts +75 -0
- package/dist/capability/erasure.d.ts.map +1 -0
- package/dist/capability/erasure.js +2 -0
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/personColumns.d.ts +28 -0
- package/dist/capability/personColumns.d.ts.map +1 -0
- package/dist/capability/personColumns.js +66 -0
- package/dist/capability/personRows.d.ts +62 -0
- package/dist/capability/personRows.d.ts.map +1 -0
- package/dist/capability/personRows.js +59 -0
- package/dist/capability/purge.d.ts +86 -19
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +83 -5
- package/dist/capability/settings.d.ts +10 -0
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/vectorIndex.d.ts +36 -0
- package/dist/capability/vectorIndex.d.ts.map +1 -0
- package/dist/capability/vectorIndex.js +25 -0
- package/dist/cli/commandCatalog.generated.d.ts +438 -3
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +454 -2
- package/dist/cli/commandFacts.d.ts +8 -5
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +12 -3
- package/dist/consent/purpose.d.ts +70 -0
- package/dist/consent/purpose.d.ts.map +1 -0
- package/dist/consent/purpose.js +97 -0
- package/dist/consent/reader.d.ts +99 -0
- package/dist/consent/reader.d.ts.map +1 -0
- package/dist/consent/reader.js +33 -0
- package/dist/controlPlane/audit/actions.d.ts +15 -4
- package/dist/controlPlane/audit/actions.d.ts.map +1 -1
- package/dist/controlPlane/audit/actions.js +16 -5
- package/dist/controlPlane/capability.d.ts +10 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +56 -8
- package/dist/controlPlane/context.d.ts +4 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -2
- package/dist/controlPlane/data/connection.d.ts +21 -2
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +13 -4
- package/dist/controlPlane/data/connections.d.ts +94 -0
- package/dist/controlPlane/data/connections.d.ts.map +1 -0
- package/dist/controlPlane/data/connections.js +151 -0
- package/dist/controlPlane/data/hold.d.ts +46 -0
- package/dist/controlPlane/data/hold.d.ts.map +1 -0
- package/dist/controlPlane/data/hold.js +36 -0
- package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
- package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
- package/dist/controlPlane/data/oidcTrust.js +173 -0
- package/dist/controlPlane/data/tables.d.ts +4 -0
- package/dist/controlPlane/data/tables.d.ts.map +1 -1
- package/dist/controlPlane/data/tables.js +9 -4
- package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
- package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
- package/dist/controlPlane/data/trustFirstCall.js +25 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +32 -8
- package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
- package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
- package/dist/controlPlane/discovery/routeInput.js +80 -0
- package/dist/controlPlane/discovery/toolName.d.ts +21 -0
- package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
- package/dist/controlPlane/discovery/toolName.js +25 -0
- package/dist/controlPlane/erasure/attest.d.ts +78 -0
- package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
- package/dist/controlPlane/erasure/attest.js +164 -0
- package/dist/controlPlane/erasure/read.d.ts +39 -0
- package/dist/controlPlane/erasure/read.d.ts.map +1 -0
- package/dist/controlPlane/erasure/read.js +140 -0
- package/dist/controlPlane/error/errors.d.ts +61 -2
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +13 -0
- package/dist/controlPlane/evidence/derive.d.ts +124 -0
- package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
- package/dist/controlPlane/evidence/derive.js +343 -0
- package/dist/controlPlane/evidence/mapping.d.ts +85 -0
- package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
- package/dist/controlPlane/evidence/mapping.js +114 -0
- package/dist/controlPlane/evidence/pack.d.ts +46 -0
- package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
- package/dist/controlPlane/evidence/pack.js +124 -0
- package/dist/controlPlane/http/connections.d.ts +96 -0
- package/dist/controlPlane/http/connections.d.ts.map +1 -0
- package/dist/controlPlane/http/connections.js +109 -0
- package/dist/controlPlane/http/guard.d.ts +23 -2
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +69 -12
- package/dist/controlPlane/http/handlers.d.ts +212 -10
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +117 -26
- package/dist/controlPlane/http/holds.d.ts +57 -0
- package/dist/controlPlane/http/holds.d.ts.map +1 -0
- package/dist/controlPlane/http/holds.js +98 -0
- package/dist/controlPlane/http/presence.d.ts +27 -0
- package/dist/controlPlane/http/presence.d.ts.map +1 -0
- package/dist/controlPlane/http/presence.js +67 -0
- package/dist/controlPlane/http/processing.d.ts +73 -0
- package/dist/controlPlane/http/processing.d.ts.map +1 -0
- package/dist/controlPlane/http/processing.js +17 -0
- package/dist/controlPlane/http/responses.d.ts +927 -3
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +283 -7
- package/dist/controlPlane/http/routes.d.ts +12 -2
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +182 -5
- package/dist/controlPlane/http/schemas.d.ts +63 -0
- package/dist/controlPlane/http/schemas.d.ts.map +1 -1
- package/dist/controlPlane/http/schemas.js +39 -1
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +13 -1
- package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
- package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
- package/dist/controlPlane/http/verifyTrust.js +75 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0005_holds.js +26 -0
- package/dist/controlPlane/processing/derive.d.ts +77 -0
- package/dist/controlPlane/processing/derive.d.ts.map +1 -0
- package/dist/controlPlane/processing/derive.js +206 -0
- package/dist/controlPlane/processing/read.d.ts +10 -0
- package/dist/controlPlane/processing/read.d.ts.map +1 -0
- package/dist/controlPlane/processing/read.js +89 -0
- package/dist/controlPlane/scope/kind.d.ts +46 -0
- package/dist/controlPlane/scope/kind.d.ts.map +1 -0
- package/dist/controlPlane/scope/kind.js +52 -0
- package/dist/controlPlane/scope/scope.d.ts +58 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +59 -4
- package/dist/controlPlane/token/jws.js +1 -1
- package/dist/controlPlane/token/mint.js +1 -1
- package/dist/controlPlane/wire.d.ts +23 -0
- package/dist/controlPlane/wire.d.ts.map +1 -1
- package/dist/controlPlane/wire.js +24 -1
- package/dist/createBackend.d.ts +13 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +11 -3
- package/dist/createEntrypoint.d.ts +7 -0
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +4 -0
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +112 -0
- package/dist/error/pithyError.d.ts +22 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +31 -1
- package/dist/http/routeSpec.d.ts +46 -1
- package/dist/http/routeSpec.d.ts.map +1 -1
- package/dist/http/routeSpec.js +39 -5
- package/dist/http/verification.d.ts.map +1 -1
- package/dist/http/verification.js +1 -1
- package/dist/i18n/translator.d.ts +2 -1
- package/dist/i18n/translator.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/logger/record.d.ts +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/mcp/annotations.d.ts +20 -0
- package/dist/mcp/annotations.d.ts.map +1 -0
- package/dist/mcp/annotations.js +23 -0
- package/dist/mcp/tools.d.ts +75 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +118 -0
- package/dist/openapi/document.d.ts.map +1 -1
- package/dist/openapi/document.js +8 -26
- package/dist/openapi/operations.d.ts +35 -0
- package/dist/openapi/operations.d.ts.map +1 -0
- package/dist/openapi/operations.js +37 -0
- package/dist/presence/channel.d.ts +11 -0
- package/dist/presence/channel.d.ts.map +1 -0
- package/dist/presence/channel.js +65 -0
- package/dist/presence/declaration.d.ts +154 -0
- package/dist/presence/declaration.d.ts.map +1 -0
- package/dist/presence/declaration.js +164 -0
- package/dist/presence/durableObject.d.ts +61 -0
- package/dist/presence/durableObject.d.ts.map +1 -0
- package/dist/presence/durableObject.js +257 -0
- package/dist/presence/frames.d.ts +166 -0
- package/dist/presence/frames.d.ts.map +1 -0
- package/dist/presence/frames.js +90 -0
- package/dist/presence/http/guard.d.ts +11 -0
- package/dist/presence/http/guard.d.ts.map +1 -0
- package/dist/presence/http/guard.js +20 -0
- package/dist/presence/http/responses.d.ts +31 -0
- package/dist/presence/http/responses.d.ts.map +1 -0
- package/dist/presence/http/responses.js +34 -0
- package/dist/presence/http/routes.d.ts +24 -0
- package/dist/presence/http/routes.d.ts.map +1 -0
- package/dist/presence/http/routes.js +127 -0
- package/dist/presence/http/schemas.d.ts +29 -0
- package/dist/presence/http/schemas.d.ts.map +1 -0
- package/dist/presence/http/schemas.js +28 -0
- package/dist/presence/names.d.ts +23 -0
- package/dist/presence/names.d.ts.map +1 -0
- package/dist/presence/names.js +22 -0
- package/dist/presence/room.d.ts +95 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +105 -0
- package/dist/presence/ticket.d.ts +55 -0
- package/dist/presence/ticket.d.ts.map +1 -0
- package/dist/presence/ticket.js +109 -0
- package/dist/privacy/classify.js +1 -1
- package/dist/privacy/encrypted.js +1 -1
- package/dist/processing/activity.d.ts +134 -0
- package/dist/processing/activity.d.ts.map +1 -0
- package/dist/processing/activity.js +161 -0
- package/dist/processing/composed.d.ts +18 -0
- package/dist/processing/composed.d.ts.map +1 -0
- package/dist/processing/composed.js +28 -0
- package/dist/processing/vocabulary.d.ts +26 -0
- package/dist/processing/vocabulary.d.ts.map +1 -0
- package/dist/processing/vocabulary.js +35 -0
- package/dist/queue/consumer.d.ts +59 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +46 -0
- package/dist/retention/composed.d.ts +37 -0
- package/dist/retention/composed.d.ts.map +1 -0
- package/dist/retention/composed.js +66 -0
- package/dist/retention/holds.d.ts +61 -0
- package/dist/retention/holds.d.ts.map +1 -0
- package/dist/retention/holds.js +107 -0
- package/dist/retention/holdsPurge.d.ts +22 -0
- package/dist/retention/holdsPurge.d.ts.map +1 -0
- package/dist/retention/holdsPurge.js +50 -0
- package/dist/retention/policy.d.ts +132 -0
- package/dist/retention/policy.d.ts.map +1 -0
- package/dist/retention/policy.js +197 -0
- package/dist/session/rotated.d.ts +34 -0
- package/dist/session/rotated.d.ts.map +1 -0
- package/dist/session/rotated.js +25 -0
- package/dist/test-utils/connectionFixtures.d.ts +13 -0
- package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
- package/dist/test-utils/connectionFixtures.js +58 -0
- package/dist/test-utils/interleave.d.ts +12 -0
- package/dist/test-utils/interleave.d.ts.map +1 -0
- package/dist/test-utils/interleave.js +26 -0
- package/dist/testWorker.d.ts +8 -0
- package/dist/testWorker.d.ts.map +1 -0
- package/dist/testWorker.js +9 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/dispatchRoute.js +1 -1
- package/package.json +1 -1
- package/src/audit/auditEvent.ts +1 -1
- package/src/audit/chain.ts +551 -0
- package/src/audit/citations.ts +63 -0
- package/src/capability/bindings.ts +61 -0
- package/src/capability/capability.ts +156 -19
- package/src/capability/erasure.ts +82 -0
- package/src/capability/personColumns.ts +83 -0
- package/src/capability/personRows.ts +125 -0
- package/src/capability/purge.ts +158 -21
- package/src/capability/settings.ts +7 -0
- package/src/capability/vectorIndex.ts +60 -0
- package/src/cli/commandCatalog.generated.ts +487 -2
- package/src/cli/commandFacts.ts +12 -3
- package/src/cloudflare-test.d.ts +3 -1
- package/src/consent/purpose.ts +160 -0
- package/src/consent/reader.ts +134 -0
- package/src/controlPlane/audit/actions.ts +15 -4
- package/src/controlPlane/capability.ts +99 -20
- package/src/controlPlane/context.ts +10 -2
- package/src/controlPlane/data/connection.ts +16 -2
- package/src/controlPlane/data/connections.ts +231 -0
- package/src/controlPlane/data/hold.ts +52 -0
- package/src/controlPlane/data/oidcTrust.ts +249 -0
- package/src/controlPlane/data/tables.ts +7 -0
- package/src/controlPlane/data/trustFirstCall.ts +37 -0
- package/src/controlPlane/discovery/adminRoute.ts +71 -9
- package/src/controlPlane/discovery/routeInput.ts +86 -0
- package/src/controlPlane/discovery/toolName.ts +23 -0
- package/src/controlPlane/erasure/attest.ts +205 -0
- package/src/controlPlane/erasure/read.ts +169 -0
- package/src/controlPlane/error/errors.ts +69 -2
- package/src/controlPlane/evidence/derive.ts +426 -0
- package/src/controlPlane/evidence/mapping.ts +118 -0
- package/src/controlPlane/evidence/pack.ts +190 -0
- package/src/controlPlane/http/connections.ts +129 -0
- package/src/controlPlane/http/guard.ts +118 -9
- package/src/controlPlane/http/handlers.ts +172 -40
- package/src/controlPlane/http/holds.ts +108 -0
- package/src/controlPlane/http/presence.ts +71 -0
- package/src/controlPlane/http/processing.ts +19 -0
- package/src/controlPlane/http/responses.ts +590 -7
- package/src/controlPlane/http/routes.ts +302 -3
- package/src/controlPlane/http/schemas.ts +126 -0
- package/src/controlPlane/http/verify.ts +19 -2
- package/src/controlPlane/http/verifyTrust.ts +176 -0
- package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
- package/src/controlPlane/migrations/0005_holds.ts +40 -0
- package/src/controlPlane/processing/derive.ts +359 -0
- package/src/controlPlane/processing/read.ts +102 -0
- package/src/controlPlane/scope/kind.ts +63 -0
- package/src/controlPlane/scope/scope.ts +65 -3
- package/src/controlPlane/wire.ts +26 -0
- package/src/createBackend.ts +31 -6
- package/src/createEntrypoint.ts +17 -0
- package/src/error/payload.ts +234 -0
- package/src/error/pithyError.ts +42 -0
- package/src/http/routeSpec.ts +75 -5
- package/src/http/verification.ts +3 -1
- package/src/i18n/translator.ts +2 -1
- package/src/index.ts +2 -0
- package/src/mcp/annotations.ts +38 -0
- package/src/mcp/tools.ts +220 -0
- package/src/openapi/document.ts +8 -28
- package/src/openapi/operations.ts +62 -0
- package/src/presence/channel.ts +46 -0
- package/src/presence/declaration.ts +283 -0
- package/src/presence/durableObject.ts +287 -0
- package/src/presence/frames.ts +153 -0
- package/src/presence/http/guard.ts +23 -0
- package/src/presence/http/responses.ts +63 -0
- package/src/presence/http/routes.ts +165 -0
- package/src/presence/http/schemas.ts +39 -0
- package/src/presence/names.ts +45 -0
- package/src/presence/room.ts +145 -0
- package/src/presence/ticket.ts +145 -0
- package/src/processing/activity.ts +289 -0
- package/src/processing/composed.ts +33 -0
- package/src/processing/vocabulary.ts +43 -0
- package/src/queue/consumer.ts +104 -0
- package/src/retention/composed.ts +75 -0
- package/src/retention/holds.ts +175 -0
- package/src/retention/holdsPurge.ts +50 -0
- package/src/retention/policy.ts +305 -0
- package/src/session/rotated.ts +53 -0
- package/src/test-utils/connectionFixtures.ts +58 -0
- package/src/test-utils/interleave.ts +31 -0
- package/src/testWorker.ts +13 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
import { ConflictError } from "../../error/pithyError";
|
|
6
|
+
import { type ConnectionCredentialKind, ControlPlaneConnection, type RegisteredKey } from "./connection";
|
|
7
|
+
import { activeKeys } from "./keyLifecycle";
|
|
8
|
+
import { activeTrusts, type OidcTrust } from "./oidcTrust";
|
|
9
|
+
import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase } from "./tables";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every connection in one database, read for a review: which person authorized each, and which still answer.
|
|
13
|
+
*
|
|
14
|
+
* `GET {basePath}/connections` and `pithy dashboard connections` both read through {@link listConnections}, so the
|
|
15
|
+
* Worker's answer and the CLI's cannot list different things.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Which credential kinds a connection holds, live or not. */
|
|
19
|
+
export function connectionCredentialKind(connection: ControlPlaneConnection): ConnectionCredentialKind {
|
|
20
|
+
const keys = connection.keys.length > 0;
|
|
21
|
+
const trusts = (connection.trusts ?? []).length > 0;
|
|
22
|
+
if (keys && trusts) return "both";
|
|
23
|
+
if (keys) return "key";
|
|
24
|
+
if (trusts) return "trust";
|
|
25
|
+
return "none";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether any key or trust on the connection can verify a call at `now`. */
|
|
29
|
+
export function connectionLive(connection: ControlPlaneConnection, now: Date): boolean {
|
|
30
|
+
return activeKeys(connection.keys, now).length > 0 || activeTrusts(connection.trusts ?? [], now).length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* When the connection was revoked: the latest revocation among its credentials, once none is live and every one of
|
|
35
|
+
* them was revoked. Null while anything is live, and for a connection whose keys merely expired.
|
|
36
|
+
*/
|
|
37
|
+
export function connectionRevokedAt(connection: ControlPlaneConnection, now: Date): Date | null {
|
|
38
|
+
if (connectionLive(connection, now)) return null;
|
|
39
|
+
const credentials = [...connection.keys, ...(connection.trusts ?? [])];
|
|
40
|
+
if (credentials.length === 0) return null;
|
|
41
|
+
let latest: Date | null = null;
|
|
42
|
+
for (const credential of credentials) {
|
|
43
|
+
if (credential.revokedAt === null) return null;
|
|
44
|
+
if (latest === null || credential.revokedAt.getTime() > latest.getTime()) latest = credential.revokedAt;
|
|
45
|
+
}
|
|
46
|
+
return latest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What {@link revokeCredentials} changed. */
|
|
50
|
+
export interface RevokedCredentials {
|
|
51
|
+
/** The connection with every key and trust not yet revoked revoked at `now`. */
|
|
52
|
+
readonly connection: ControlPlaneConnection;
|
|
53
|
+
readonly revokedKeyIds: readonly string[];
|
|
54
|
+
readonly revokedTrustIds: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Revoke every key and trust not yet revoked, whatever its window. One that has not started will verify once its
|
|
59
|
+
* window opens, and the revoker's clock may run behind the Worker that stamped it, so it is revoked too. A credential
|
|
60
|
+
* already revoked keeps its instant.
|
|
61
|
+
*/
|
|
62
|
+
export function revokeCredentials(connection: ControlPlaneConnection, now: Date): RevokedCredentials {
|
|
63
|
+
const keyIds = connection.keys.filter((key) => key.revokedAt === null).map((key) => key.keyId);
|
|
64
|
+
const trustIds = (connection.trusts ?? []).filter((trust) => trust.revokedAt === null).map((trust) => trust.trustId);
|
|
65
|
+
return {
|
|
66
|
+
connection: {
|
|
67
|
+
...connection,
|
|
68
|
+
keys: connection.keys.map((key) => (key.revokedAt === null ? { ...key, revokedAt: now } : key)),
|
|
69
|
+
trusts: (connection.trusts ?? []).map((trust) =>
|
|
70
|
+
trust.revokedAt === null ? { ...trust, revokedAt: now } : trust,
|
|
71
|
+
),
|
|
72
|
+
updatedAt: keyIds.length + trustIds.length > 0 ? now : connection.updatedAt,
|
|
73
|
+
},
|
|
74
|
+
revokedKeyIds: keyIds,
|
|
75
|
+
revokedTrustIds: trustIds,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a connection was authorized by `principal`. An email, compared without regard to case. */
|
|
80
|
+
export function authorizedByPrincipal(connection: ControlPlaneConnection, principal: string): boolean {
|
|
81
|
+
return connection.authorizedBy !== null && connection.authorizedBy.toLowerCase() === principal.toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Which connections {@link listConnections} reads. */
|
|
85
|
+
export interface ConnectionFilter {
|
|
86
|
+
/** Only connections this email authorized. Unattributed connections are listed apart either way. */
|
|
87
|
+
readonly principal?: string | undefined;
|
|
88
|
+
/** Only connections bound to this environment. */
|
|
89
|
+
readonly environment?: string | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The connections a filter selects, with the ones no principal was recorded for kept apart. */
|
|
93
|
+
export interface ConnectionListing {
|
|
94
|
+
/** Connections with a recorded principal, matching the filter, oldest first. */
|
|
95
|
+
readonly connections: readonly ControlPlaneConnection[];
|
|
96
|
+
/** Connections registered before principals were recorded, oldest first. Never matched by a principal. */
|
|
97
|
+
readonly unattributed: readonly ControlPlaneConnection[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every connection in the database, attributed ones matching the filter, unattributed ones apart. */
|
|
101
|
+
export async function listConnections(
|
|
102
|
+
db: ControlPlaneDatabase,
|
|
103
|
+
filter: ConnectionFilter = {},
|
|
104
|
+
): Promise<ConnectionListing> {
|
|
105
|
+
let query = db.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE).selectAll().orderBy("createdAt").orderBy("id");
|
|
106
|
+
if (filter.environment !== undefined) query = query.where("environment", "=", filter.environment);
|
|
107
|
+
const rows = (await query.execute()).map((row) => ControlPlaneConnection.parse(row));
|
|
108
|
+
const { principal } = filter;
|
|
109
|
+
return {
|
|
110
|
+
connections: rows.filter(
|
|
111
|
+
(row) => row.authorizedBy !== null && (principal === undefined || authorizedByPrincipal(row, principal)),
|
|
112
|
+
),
|
|
113
|
+
unattributed: rows.filter((row) => row.authorizedBy === null),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read one connection by id, or null. */
|
|
118
|
+
export async function readConnection(db: ControlPlaneDatabase, id: string): Promise<ControlPlaneConnection | null> {
|
|
119
|
+
const row = await db.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE).selectAll().where("id", "=", id).executeTakeFirst();
|
|
120
|
+
return row ? ControlPlaneConnection.parse(row) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A connection read for a write to its credential columns, with those columns exactly as stored. A write made from it
|
|
125
|
+
* lands only while they are unchanged, so a revocation landing in between is never overwritten.
|
|
126
|
+
*/
|
|
127
|
+
export interface ConnectionForWrite {
|
|
128
|
+
readonly connection: ControlPlaneConnection;
|
|
129
|
+
/** The keys column as stored, compared as stored. */
|
|
130
|
+
readonly storedKeys: unknown;
|
|
131
|
+
/** The trusts column as stored, compared as stored. */
|
|
132
|
+
readonly storedTrusts: unknown;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read one connection by id for a write, or null. With `environment`, null for a connection bound to another. */
|
|
136
|
+
export async function readConnectionForWrite(
|
|
137
|
+
db: ControlPlaneDatabase,
|
|
138
|
+
id: string,
|
|
139
|
+
options: { readonly environment?: string | undefined } = {},
|
|
140
|
+
): Promise<ConnectionForWrite | null> {
|
|
141
|
+
let query = db.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE).selectAll().where("id", "=", id);
|
|
142
|
+
if (options.environment !== undefined) query = query.where("environment", "=", options.environment);
|
|
143
|
+
const row = await query.executeTakeFirst();
|
|
144
|
+
if (!row) return null;
|
|
145
|
+
return { connection: ControlPlaneConnection.parse(row), storedKeys: row.keys, storedTrusts: row.trusts };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write a connection's keys and stamp `updatedAt`, only while its keys column still holds what `read` saw. False
|
|
150
|
+
* when it no longer does: another write, a revocation among them, landed in between and nothing was written.
|
|
151
|
+
*/
|
|
152
|
+
export async function writeKeys(
|
|
153
|
+
db: ControlPlaneDatabase,
|
|
154
|
+
read: ConnectionForWrite,
|
|
155
|
+
keys: readonly RegisteredKey[],
|
|
156
|
+
now: Date,
|
|
157
|
+
): Promise<boolean> {
|
|
158
|
+
const result = await db
|
|
159
|
+
.updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
160
|
+
.set({
|
|
161
|
+
keys: ControlPlaneConnection.shape.keys.encode([...keys]),
|
|
162
|
+
updatedAt: ControlPlaneConnection.shape.updatedAt.encode(now),
|
|
163
|
+
})
|
|
164
|
+
.where("id", "=", read.connection.id)
|
|
165
|
+
.where(sql<boolean>`${sql.ref("keys")} = ${read.storedKeys}`)
|
|
166
|
+
.executeTakeFirst();
|
|
167
|
+
return Number(result.numUpdatedRows) > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The credential columns a {@link writeCredentials} call replaces. A column left out is not written. */
|
|
171
|
+
export interface CredentialColumns {
|
|
172
|
+
readonly keys?: readonly RegisteredKey[] | undefined;
|
|
173
|
+
readonly trusts?: readonly OidcTrust[] | undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Write the given credential columns and stamp `updatedAt`, only while both the keys and the trusts columns still hold
|
|
178
|
+
* what `read` saw. False when either changed in between, and nothing was written.
|
|
179
|
+
*/
|
|
180
|
+
export async function writeCredentials(
|
|
181
|
+
db: ControlPlaneDatabase,
|
|
182
|
+
read: ConnectionForWrite,
|
|
183
|
+
columns: CredentialColumns,
|
|
184
|
+
now: Date,
|
|
185
|
+
): Promise<boolean> {
|
|
186
|
+
const result = await db
|
|
187
|
+
.updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
188
|
+
.set({
|
|
189
|
+
...(columns.keys === undefined ? {} : { keys: ControlPlaneConnection.shape.keys.encode([...columns.keys]) }),
|
|
190
|
+
...(columns.trusts === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { trusts: ControlPlaneConnection.shape.trusts.encode([...columns.trusts]) }),
|
|
193
|
+
updatedAt: ControlPlaneConnection.shape.updatedAt.encode(now),
|
|
194
|
+
})
|
|
195
|
+
.where("id", "=", read.connection.id)
|
|
196
|
+
.where(sql<boolean>`${sql.ref("keys")} = ${read.storedKeys}`)
|
|
197
|
+
.where(sql<boolean>`${sql.ref("trusts")} = ${read.storedTrusts}`)
|
|
198
|
+
.executeTakeFirst();
|
|
199
|
+
return Number(result.numUpdatedRows) > 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** How many times {@link revokeConnection} reads again after a write landed between its read and its own. */
|
|
203
|
+
const REVOKE_ATTEMPTS = 5;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Revoke every key and trust of one connection not yet revoked, or answer null when no connection has that id. With
|
|
207
|
+
* `environment`, a connection bound to another environment answers null too.
|
|
208
|
+
*
|
|
209
|
+
* Writes the two credential columns and the stamp and nothing else on the row, and only while both columns still
|
|
210
|
+
* hold what it read; when another write landed in between, it reads again.
|
|
211
|
+
*/
|
|
212
|
+
export async function revokeConnection(
|
|
213
|
+
db: ControlPlaneDatabase,
|
|
214
|
+
id: string,
|
|
215
|
+
now: Date,
|
|
216
|
+
options: { readonly environment?: string | undefined } = {},
|
|
217
|
+
): Promise<RevokedCredentials | null> {
|
|
218
|
+
for (let attempt = 0; attempt < REVOKE_ATTEMPTS; attempt++) {
|
|
219
|
+
const read = await readConnectionForWrite(db, id, options);
|
|
220
|
+
if (read === null) return null;
|
|
221
|
+
const revoked = revokeCredentials(read.connection, now);
|
|
222
|
+
if (revoked.revokedKeyIds.length + revoked.revokedTrustIds.length === 0) return revoked;
|
|
223
|
+
const columns = { keys: revoked.connection.keys, trusts: revoked.connection.trusts ?? [] };
|
|
224
|
+
if (await writeCredentials(db, read, columns, now)) return revoked;
|
|
225
|
+
}
|
|
226
|
+
throw new ConflictError({
|
|
227
|
+
message: "The connection kept changing while it was being revoked.",
|
|
228
|
+
action: "Revoke it again.",
|
|
229
|
+
detail: `connection ${id} changed between read and write ${REVOKE_ATTEMPTS} times`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { SQLiteDate } from "../../data/codecs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **A legal hold: one subject preserved, with the reason it was placed and, once released, why.**
|
|
9
|
+
*
|
|
10
|
+
* Placed and released over the control plane, behind `retention:hold`, because a hold is operational and urgent:
|
|
11
|
+
* counsel says preserve everything about this customer, and the next scheduled purge is tonight. While a hold stands
|
|
12
|
+
* no purge, prune or inactivity deletion touches its subject. Released holds are kept — the row is the record that
|
|
13
|
+
* one stood, from when to when, and why it ended — so nothing here is ever deleted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What a hold preserves: one user, one organization, or one tenant's audit trail. */
|
|
17
|
+
export const HoldSubjectType = z
|
|
18
|
+
.enum(["user", "organization", "audit"])
|
|
19
|
+
.describe(
|
|
20
|
+
"What the hold preserves: `user`, one account and everything its purge would erase; `organization`, one tenant and everything its purge would erase; `audit`, one tenant's audit trail, which the prune then leaves whole.",
|
|
21
|
+
);
|
|
22
|
+
export type HoldSubjectType = z.infer<typeof HoldSubjectType>;
|
|
23
|
+
|
|
24
|
+
/** Whether a hold is in force. */
|
|
25
|
+
export const HoldState = z
|
|
26
|
+
.enum(["active", "released"])
|
|
27
|
+
.describe("`active` while the hold stands; `released` once it has been lifted, with the reason.");
|
|
28
|
+
export type HoldState = z.infer<typeof HoldState>;
|
|
29
|
+
|
|
30
|
+
/** One hold, as `pithy_controlplane_holds` stores it. */
|
|
31
|
+
export const LegalHold = z
|
|
32
|
+
.object({
|
|
33
|
+
id: z.string().min(1).describe("The hold's id. Text, never a sequence: it is named on a release route."),
|
|
34
|
+
subjectType: HoldSubjectType,
|
|
35
|
+
subjectId: z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(255)
|
|
39
|
+
.nullable()
|
|
40
|
+
.describe(
|
|
41
|
+
"The held user's or organization's id, or the tenant whose audit trail is held. Null only for an `audit` hold on the untenanted trail.",
|
|
42
|
+
),
|
|
43
|
+
reason: z.string().min(1).max(500).describe("Why the hold was placed, in whoever placed it's words."),
|
|
44
|
+
placedBy: z.string().describe("The management client's own user id that placed it — the token's `sub`."),
|
|
45
|
+
placedAt: SQLiteDate.describe("When the hold was placed. Ms-epoch in SQLite."),
|
|
46
|
+
state: HoldState,
|
|
47
|
+
releasedAt: SQLiteDate.nullable().describe("When the hold was released, or null while it stands."),
|
|
48
|
+
releasedBy: z.string().nullable().describe("Who released it, or null while it stands."),
|
|
49
|
+
releaseReason: z.string().max(500).nullable().describe("Why it was released, or null while it stands."),
|
|
50
|
+
})
|
|
51
|
+
.describe("A legal hold on one user, one organization or one tenant's audit trail.");
|
|
52
|
+
export type LegalHold = z.output<typeof LegalHold>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { JsonDate } from "../../data/codecs";
|
|
6
|
+
import { ControlPlaneKeyConflictError } from "../error/errors";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An OIDC trust: the second credential kind a connection may hold, beside a registered key
|
|
10
|
+
* (`docs/AGENT-ACCESS.md` §6.2, #722).
|
|
11
|
+
*
|
|
12
|
+
* A key is a bearer: one client holds the private half. A trust is a **policy**: a trusted issuer, the
|
|
13
|
+
* audience its tokens must name, and a matcher over the claims that say which workload a token was minted
|
|
14
|
+
* for. Nobody holds a secret. The workload asks its own identity provider for a short-lived token, and the
|
|
15
|
+
* Worker checks it against the issuer's published keys with the verifier the webhooks already use,
|
|
16
|
+
* `core/src/http/oidcWebhook.ts`.
|
|
17
|
+
*
|
|
18
|
+
* ## The claims become data here, and that is a deliberate departure
|
|
19
|
+
*
|
|
20
|
+
* In a webhook the claims check is a predicate the **route** holds, because whoever wrote the route knows
|
|
21
|
+
* the legitimate sender. A connection's trust is per adopter, decided at registration by the same person who
|
|
22
|
+
* grants the scopes, so the matcher is stored with the trust and validated on the way in. That is the whole
|
|
23
|
+
* reason {@link OidcClaimMatch} exists rather than a function.
|
|
24
|
+
*
|
|
25
|
+
* ## The claims check is the boundary, not the signature
|
|
26
|
+
*
|
|
27
|
+
* An identity provider signs for everybody with the same keys. GitHub signs a token for every repository on
|
|
28
|
+
* the planet. A trust whose matcher constrains nothing accepts all of them, so {@link OidcClaimMatch} refuses
|
|
29
|
+
* one at registration — structurally, not as a warning — and refuses the claims that name the token rather
|
|
30
|
+
* than the workload ({@link RESERVED_OIDC_CLAIMS}), since a matcher made of those constrains nothing either.
|
|
31
|
+
*
|
|
32
|
+
* ## Named claims, not the `sub` string
|
|
33
|
+
*
|
|
34
|
+
* A `sub` is a concatenation whose composition belongs to the provider and to the adopter's own template.
|
|
35
|
+
* The claims beneath it — GitHub's `repository_id`, `environment`, `ref`; GitLab's `project_id` — are
|
|
36
|
+
* published individually and are stable. So the matcher is a map of claim names to values, and `sub` is one
|
|
37
|
+
* claim among the others rather than a special case.
|
|
38
|
+
*
|
|
39
|
+
* ## No rotation, and revocation changes shape
|
|
40
|
+
*
|
|
41
|
+
* There is no key to append, prove and expire. A token cannot be revoked; the trust is, and every token
|
|
42
|
+
* already minted stays live until it expires — {@link TRUST_REVOCATION_LATENCY} is the sentence that says
|
|
43
|
+
* so at the moment it matters.
|
|
44
|
+
*
|
|
45
|
+
* Nothing in this module reads a clock, a binding or D1. `now` is injected wherever time matters.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Claims that name the token rather than the workload: who minted it, for whom, and when. A matcher built
|
|
50
|
+
* from these alone would accept every workload the issuer serves, so none of them may be registered.
|
|
51
|
+
* `iss` and `aud` are checked anyway, by the trust's own issuer and audience.
|
|
52
|
+
*/
|
|
53
|
+
export const RESERVED_OIDC_CLAIMS = ["iss", "aud", "exp", "nbf", "iat", "jti"] as const;
|
|
54
|
+
|
|
55
|
+
/** The most claims one trust may match on. Far above any real constraint; a bound on a stored row. */
|
|
56
|
+
export const MAX_TRUST_CLAIMS = 16;
|
|
57
|
+
|
|
58
|
+
/** The most claims a refusal records, so a token carrying a hundred cannot fill the audit trail. */
|
|
59
|
+
export const MAX_OBSERVED_CLAIMS = 32;
|
|
60
|
+
|
|
61
|
+
/** The longest claim value a matcher stores or a refusal records. */
|
|
62
|
+
export const MAX_CLAIM_VALUE = 256;
|
|
63
|
+
|
|
64
|
+
/** A claim name: the plain alphabet providers use, plus `.`, `:` and `-` for a customized template. */
|
|
65
|
+
const CLAIM_NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
66
|
+
|
|
67
|
+
/** Every C0 and C1 control, the line separators, and the bidi controls: a value is one line of plain text. */
|
|
68
|
+
const UNRENDERABLE = /[\p{Cc}\p{Bidi_Control}\u2028\u2029]/u;
|
|
69
|
+
|
|
70
|
+
const reserved = new Set<string>(RESERVED_OIDC_CLAIMS);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One claim name in a matcher. A name, not a path: nested claims are not matched, because a matcher that
|
|
74
|
+
* walks a structure is a matcher with more ways to be wrong on the check the whole trust rests on.
|
|
75
|
+
*/
|
|
76
|
+
export const OidcClaimName = z
|
|
77
|
+
.string()
|
|
78
|
+
.regex(CLAIM_NAME, "A claim name is letters, digits, `_`, `.`, `:` or `-`, at most 64.")
|
|
79
|
+
.refine((name) => !reserved.has(name), {
|
|
80
|
+
message: `${RESERVED_OIDC_CLAIMS.join(", ")} name the token, not the workload, so they cannot constrain a trust.`,
|
|
81
|
+
})
|
|
82
|
+
.describe(
|
|
83
|
+
"A claim the token must carry, by name — `repository_id`, `environment`, `project_id`, `sub`. Never one of the claims that name the token itself.",
|
|
84
|
+
);
|
|
85
|
+
export type OidcClaimName = z.infer<typeof OidcClaimName>;
|
|
86
|
+
|
|
87
|
+
/** One claim value in a matcher: compared as a string against the token's claim of the same name. */
|
|
88
|
+
export const OidcClaimValue = z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1, "A claim value cannot be empty.")
|
|
91
|
+
.max(MAX_CLAIM_VALUE, `A claim value is ${MAX_CLAIM_VALUE} characters at most.`)
|
|
92
|
+
.refine((value) => !UNRENDERABLE.test(value), "A claim value is one line of plain text.")
|
|
93
|
+
.describe(
|
|
94
|
+
"The exact value that claim must have. A numeric or boolean claim is compared by its string form, so `1262274726` matches a `repository_id` whichever way the provider typed it.",
|
|
95
|
+
);
|
|
96
|
+
export type OidcClaimValue = z.infer<typeof OidcClaimValue>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Which workloads a trust accepts: every named claim must equal its value.
|
|
100
|
+
*
|
|
101
|
+
* **At least one, and that is the refusal the whole credential kind rests on.** An issuer with no claim
|
|
102
|
+
* constraint accepts every workload it serves.
|
|
103
|
+
*/
|
|
104
|
+
export const OidcClaimMatch = z
|
|
105
|
+
.record(OidcClaimName, OidcClaimValue)
|
|
106
|
+
.refine((claims) => Object.keys(claims).length > 0, {
|
|
107
|
+
message: "A trust needs at least one claim constraint. An issuer alone accepts every workload it serves.",
|
|
108
|
+
})
|
|
109
|
+
.refine((claims) => Object.keys(claims).length <= MAX_TRUST_CLAIMS, {
|
|
110
|
+
message: `A trust matches on ${MAX_TRUST_CLAIMS} claims at most.`,
|
|
111
|
+
})
|
|
112
|
+
.describe(
|
|
113
|
+
"The subject constraint: every named claim must equal its value. At least one, because an issuer alone accepts every workload it serves.",
|
|
114
|
+
);
|
|
115
|
+
export type OidcClaimMatch = z.infer<typeof OidcClaimMatch>;
|
|
116
|
+
|
|
117
|
+
/** An https URL. A trust's issuer and key endpoint are fetched from, so plain http is refused. */
|
|
118
|
+
const HttpsUrl = z.url({ protocol: /^https$/ });
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One registered OIDC trust, an element of the connection's `trusts` column.
|
|
122
|
+
*
|
|
123
|
+
* Stored beside `keys` rather than inside it: the two kinds share nothing but the connection they grant
|
|
124
|
+
* through, and a union array would make every key reader learn about trusts.
|
|
125
|
+
*/
|
|
126
|
+
export const OidcTrust = z
|
|
127
|
+
.object({
|
|
128
|
+
trustId: z
|
|
129
|
+
.string()
|
|
130
|
+
.min(1)
|
|
131
|
+
.max(64)
|
|
132
|
+
.describe(
|
|
133
|
+
"The trust's id, named by a call on the `pithy-control-plane-trust` header the way a token's `kid` names a key. Not a secret.",
|
|
134
|
+
),
|
|
135
|
+
issuer: HttpsUrl.describe(
|
|
136
|
+
"The exact `iss` this trust accepts — `https://token.actions.githubusercontent.com`, `https://gitlab.com`. A token from any other issuer is refused before its claims are read.",
|
|
137
|
+
),
|
|
138
|
+
jwksUrl: HttpsUrl.describe(
|
|
139
|
+
"Where the issuer publishes the keys that sign its tokens, read from its discovery document at registration. Public, so configuration and not a secret.",
|
|
140
|
+
),
|
|
141
|
+
audience: z
|
|
142
|
+
.string()
|
|
143
|
+
.min(1)
|
|
144
|
+
.max(512)
|
|
145
|
+
.describe(
|
|
146
|
+
"The `aud` a token must name. Defaults to this environment's own Worker origin, which is what stops a staging token being replayed against production.",
|
|
147
|
+
),
|
|
148
|
+
claims: OidcClaimMatch,
|
|
149
|
+
authorizedBy: z
|
|
150
|
+
.email()
|
|
151
|
+
.max(254)
|
|
152
|
+
.describe(
|
|
153
|
+
"Who sanctioned this trust: the signed-in session's email at registration. Never who may use it — a trust's users are whoever its claims admit.",
|
|
154
|
+
),
|
|
155
|
+
validFrom: JsonDate.describe("When the trust was registered and began to be honored."),
|
|
156
|
+
revokedAt: JsonDate.nullable().describe(
|
|
157
|
+
"When the trust was revoked, or null. Takes effect on the next call; a token already minted is refused from then on, but stays in its holder's hands until it expires.",
|
|
158
|
+
),
|
|
159
|
+
firstCallAt: JsonDate.nullable().describe(
|
|
160
|
+
"When a call first verified under this trust, or null while it is awaiting a first call. Nothing can prove a trust at registration — there is no private key here and no workload present — so this is the only proof there is.",
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
.describe(
|
|
164
|
+
"One OIDC trust: a trusted issuer, the audience its tokens must name, and the claims that say which workloads it admits.",
|
|
165
|
+
);
|
|
166
|
+
export type OidcTrust = z.output<typeof OidcTrust>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What a caller should expect after a trust is revoked, said at the moment it is revoked rather than
|
|
170
|
+
* discovered afterwards.
|
|
171
|
+
*/
|
|
172
|
+
export const TRUST_REVOCATION_LATENCY =
|
|
173
|
+
"A token cannot be revoked, only the trust. Every call naming this trust is refused from now on, but a token already minted stays in its holder's hands until it expires — the issuer's own lifetime, typically minutes.";
|
|
174
|
+
|
|
175
|
+
/** Every trust that may verify a call at `now`: not revoked, and started. */
|
|
176
|
+
export function activeTrusts(trusts: readonly OidcTrust[], now: Date): OidcTrust[] {
|
|
177
|
+
return trusts.filter((trust) => trust.revokedAt === null && trust.validFrom.getTime() <= now.getTime());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The trust a call names, if it is live at `now`, or null. The revocation check lives here so no caller forgets it. */
|
|
181
|
+
export function findVerifyingTrust(trusts: readonly OidcTrust[], trustId: string, now: Date): OidcTrust | null {
|
|
182
|
+
return activeTrusts(trusts, now).find((trust) => trust.trustId === trustId) ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Register a trust beside the others. A duplicate id is refused: the id is what a call addresses. */
|
|
186
|
+
export function appendTrust(trusts: readonly OidcTrust[], trust: OidcTrust): OidcTrust[] {
|
|
187
|
+
if (trusts.some((existing) => existing.trustId === trust.trustId)) {
|
|
188
|
+
throw new ControlPlaneKeyConflictError({
|
|
189
|
+
message: "That trust id is already registered on this connection.",
|
|
190
|
+
action: "Register the trust under a new id.",
|
|
191
|
+
detail: `duplicate trustId ${trust.trustId}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return [...trusts, trust];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Revoke one trust at `now`, or answer null when no trust answers to that id. A trust already revoked keeps
|
|
199
|
+
* the instant it was first revoked: the window a call was refused under must not move.
|
|
200
|
+
*/
|
|
201
|
+
export function revokeTrust(trusts: readonly OidcTrust[], trustId: string, now: Date): OidcTrust[] | null {
|
|
202
|
+
if (!trusts.some((trust) => trust.trustId === trustId)) return null;
|
|
203
|
+
return trusts.map((trust) =>
|
|
204
|
+
trust.trustId === trustId && trust.revokedAt === null ? { ...trust, revokedAt: now } : trust,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A claim's string form, or undefined for a value that is not a string, number or boolean. */
|
|
209
|
+
function claimString(value: unknown): string | undefined {
|
|
210
|
+
if (typeof value === "string") return value;
|
|
211
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
212
|
+
if (typeof value === "boolean") return String(value);
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether a token's claims satisfy a trust's matcher: every registered claim present and equal.
|
|
218
|
+
*
|
|
219
|
+
* An empty matcher matches nothing. Registration refuses one, and this refuses it again rather than letting
|
|
220
|
+
* a hand-edited row vacuously admit every workload.
|
|
221
|
+
*/
|
|
222
|
+
export function claimsMatch(
|
|
223
|
+
match: Readonly<Record<string, string>>,
|
|
224
|
+
claims: Readonly<Record<string, unknown>>,
|
|
225
|
+
): boolean {
|
|
226
|
+
const entries = Object.entries(match);
|
|
227
|
+
if (entries.length === 0) return false;
|
|
228
|
+
return entries.every(([name, expected]) => claimString(claims[name]) === expected);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The claims a refused token carried, as a refusal records them: the workload's claims, as strings, capped.
|
|
233
|
+
*
|
|
234
|
+
* This is what makes a custom issuer workable and a profiled one self-correcting — register, let the
|
|
235
|
+
* workload run once, read what it actually sent. Only ever called once the signature, issuer and audience
|
|
236
|
+
* have verified, so the caller demonstrably holds a genuine token for this audience and naming its claims
|
|
237
|
+
* leaks nothing it does not already hold.
|
|
238
|
+
*/
|
|
239
|
+
export function observedClaims(claims: Readonly<Record<string, unknown>>): Record<string, string> {
|
|
240
|
+
const recorded: Record<string, string> = {};
|
|
241
|
+
for (const [name, value] of Object.entries(claims)) {
|
|
242
|
+
if (Object.keys(recorded).length >= MAX_OBSERVED_CLAIMS) break;
|
|
243
|
+
if (reserved.has(name) || !CLAIM_NAME.test(name)) continue;
|
|
244
|
+
const text = claimString(value);
|
|
245
|
+
if (text === undefined) continue;
|
|
246
|
+
recorded[name] = text.slice(0, MAX_CLAIM_VALUE);
|
|
247
|
+
}
|
|
248
|
+
return recorded;
|
|
249
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { Kysely } from "kysely";
|
|
|
6
6
|
import type { z } from "zod";
|
|
7
7
|
import { createDatabase, type DatabaseSchema } from "../../data/db";
|
|
8
8
|
import { ControlPlaneConnection } from "./connection";
|
|
9
|
+
import { LegalHold } from "./hold";
|
|
9
10
|
import { ControlPlaneReplay } from "./replay";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -23,6 +24,9 @@ export const CONTROL_PLANE_CONNECTIONS_TABLE = "pithyControlplaneConnections";
|
|
|
23
24
|
*/
|
|
24
25
|
export const CONTROL_PLANE_REPLAYS_TABLE = "pithyControlplaneReplays";
|
|
25
26
|
|
|
27
|
+
/** Legal holds, kept after release as the record that one stood. */
|
|
28
|
+
export const CONTROL_PLANE_HOLDS_TABLE = "pithyControlplaneHolds";
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* The control-plane tables map. Both are always present: a Worker that composes the seam and has never
|
|
28
32
|
* been connected still needs somewhere for the answer "nobody is connected" to come from, and the
|
|
@@ -32,6 +36,7 @@ export function controlPlaneTables(): Record<string, z.ZodObject> {
|
|
|
32
36
|
return {
|
|
33
37
|
[CONTROL_PLANE_CONNECTIONS_TABLE]: ControlPlaneConnection,
|
|
34
38
|
[CONTROL_PLANE_REPLAYS_TABLE]: ControlPlaneReplay,
|
|
39
|
+
[CONTROL_PLANE_HOLDS_TABLE]: LegalHold,
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -39,6 +44,7 @@ export function controlPlaneTables(): Record<string, z.ZodObject> {
|
|
|
39
44
|
export type ControlPlaneTables = {
|
|
40
45
|
[CONTROL_PLANE_CONNECTIONS_TABLE]: typeof ControlPlaneConnection;
|
|
41
46
|
[CONTROL_PLANE_REPLAYS_TABLE]: typeof ControlPlaneReplay;
|
|
47
|
+
[CONTROL_PLANE_HOLDS_TABLE]: typeof LegalHold;
|
|
42
48
|
};
|
|
43
49
|
export type ControlPlaneDatabase = Kysely<DatabaseSchema<ControlPlaneTables>>;
|
|
44
50
|
|
|
@@ -47,5 +53,6 @@ export function controlPlaneDatabase(d1: D1Database): ControlPlaneDatabase {
|
|
|
47
53
|
return createDatabase(d1, {
|
|
48
54
|
[CONTROL_PLANE_CONNECTIONS_TABLE]: ControlPlaneConnection,
|
|
49
55
|
[CONTROL_PLANE_REPLAYS_TABLE]: ControlPlaneReplay,
|
|
56
|
+
[CONTROL_PLANE_HOLDS_TABLE]: LegalHold,
|
|
50
57
|
}) as unknown as ControlPlaneDatabase;
|
|
51
58
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase } from "./tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Record the first call a trust answered (#722), so a listing stops saying *awaiting a first call*.
|
|
9
|
+
*
|
|
10
|
+
* **One statement that touches one field, never a read-modify-write of the column.** The verifier read the row
|
|
11
|
+
* moments ago, and an operator may have revoked the trust since. Writing back the whole `trusts` array as the
|
|
12
|
+
* verifier saw it would resurrect that trust — a revocation undone by the very call it was meant to stop. So the
|
|
13
|
+
* write is `json_set` on the matching element's `firstCallAt` alone, and only while that element is still
|
|
14
|
+
* unrevoked and still uncalled: a revoked trust is left exactly as revoked, and a second first call changes nothing.
|
|
15
|
+
*
|
|
16
|
+
* The instant is written as ISO-8601, the shape `JsonDate` stores inside the column.
|
|
17
|
+
*/
|
|
18
|
+
export async function recordTrustFirstCall(
|
|
19
|
+
db: ControlPlaneDatabase,
|
|
20
|
+
connectionId: string,
|
|
21
|
+
trustId: string,
|
|
22
|
+
at: Date,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const trusts = sql.ref("trusts");
|
|
25
|
+
const uncalled = (alias: string) =>
|
|
26
|
+
sql`json_extract(${sql.ref(`${alias}.value`)}, '$.trustId') = ${trustId}
|
|
27
|
+
and json_extract(${sql.ref(`${alias}.value`)}, '$.revokedAt') is null
|
|
28
|
+
and json_extract(${sql.ref(`${alias}.value`)}, '$.firstCallAt') is null`;
|
|
29
|
+
await db
|
|
30
|
+
.updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
31
|
+
.set({
|
|
32
|
+
trusts: sql<string>`json_set(${trusts}, '$[' || (select ${sql.ref("t.key")} from json_each(${trusts}) as t where ${uncalled("t")} limit 1) || '].firstCallAt', ${at.toISOString()})`,
|
|
33
|
+
})
|
|
34
|
+
.where("id", "=", connectionId)
|
|
35
|
+
.where(sql<boolean>`exists (select 1 from json_each(${trusts}) as u where ${uncalled("u")})`)
|
|
36
|
+
.execute();
|
|
37
|
+
}
|