@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,175 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { type HoldSubjectType, LegalHold } from "../controlPlane/data/hold";
|
|
6
|
+
import {
|
|
7
|
+
CONTROL_PLANE_HOLDS_TABLE,
|
|
8
|
+
type ControlPlaneDatabase,
|
|
9
|
+
controlPlaneDatabase,
|
|
10
|
+
} from "../controlPlane/data/tables";
|
|
11
|
+
import { SQLiteDate } from "../data/codecs";
|
|
12
|
+
import { ConflictError, NotFoundError } from "../error/pithyError";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **Legal holds: placing, releasing and reading them, and the one question every purge asks.**
|
|
16
|
+
*
|
|
17
|
+
* The holds live in the control plane's table in the app database, `DB`. A capability that erases — auth's purge,
|
|
18
|
+
* organization's purge, audit's prune, auth's inactivity pass — cannot import the control plane, so it asks through
|
|
19
|
+
* {@link legalHolds}, over the env its Workflow was handed, before every step that would touch a subject.
|
|
20
|
+
*
|
|
21
|
+
* **A hold that cannot be read is not an absent hold.** Only one answer reads as "nothing is held" without a row: a
|
|
22
|
+
* database that has no hold table, because no control plane ever migrated it, so nothing could have placed one. Any
|
|
23
|
+
* other failure throws, and the step that asked fails and retries rather than erasing past a hold it could not see.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Who places or releases a hold: the management client's own user id. */
|
|
27
|
+
export interface HoldActor {
|
|
28
|
+
readonly subject: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Place a hold. Holds stack: a second hold on one subject is its own row, released on its own. */
|
|
32
|
+
export async function placeHold(
|
|
33
|
+
db: ControlPlaneDatabase,
|
|
34
|
+
input: { subjectType: HoldSubjectType; subjectId: string | null; reason: string; actor: HoldActor; at: Date },
|
|
35
|
+
): Promise<LegalHold> {
|
|
36
|
+
const hold: LegalHold = {
|
|
37
|
+
id: crypto.randomUUID(),
|
|
38
|
+
subjectType: input.subjectType,
|
|
39
|
+
subjectId: input.subjectId,
|
|
40
|
+
reason: input.reason,
|
|
41
|
+
placedBy: input.actor.subject,
|
|
42
|
+
placedAt: input.at,
|
|
43
|
+
state: "active",
|
|
44
|
+
releasedAt: null,
|
|
45
|
+
releasedBy: null,
|
|
46
|
+
releaseReason: null,
|
|
47
|
+
};
|
|
48
|
+
await db.insertInto(CONTROL_PLANE_HOLDS_TABLE).values(LegalHold.encode(hold)).execute();
|
|
49
|
+
return hold;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Release a hold, with a reason. A hold already released is a conflict, never a second release: the row keeps the
|
|
54
|
+
* first one's who and why. An unknown id is a 404.
|
|
55
|
+
*/
|
|
56
|
+
export async function releaseHold(
|
|
57
|
+
db: ControlPlaneDatabase,
|
|
58
|
+
input: { id: string; reason: string; actor: HoldActor; at: Date },
|
|
59
|
+
): Promise<LegalHold> {
|
|
60
|
+
const existing = await readHold(db, input.id);
|
|
61
|
+
if (!existing) {
|
|
62
|
+
throw new NotFoundError({ message: "No hold has that id.", detail: `hold ${input.id} not found` });
|
|
63
|
+
}
|
|
64
|
+
const result = await db
|
|
65
|
+
.updateTable(CONTROL_PLANE_HOLDS_TABLE)
|
|
66
|
+
.set({
|
|
67
|
+
state: "released",
|
|
68
|
+
releasedAt: SQLiteDate.encode(input.at),
|
|
69
|
+
releasedBy: input.actor.subject,
|
|
70
|
+
releaseReason: input.reason,
|
|
71
|
+
})
|
|
72
|
+
.where("id", "=", input.id)
|
|
73
|
+
.where("state", "=", "active")
|
|
74
|
+
.executeTakeFirst();
|
|
75
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
76
|
+
throw new ConflictError({
|
|
77
|
+
message: "That hold is already released.",
|
|
78
|
+
detail: `hold ${input.id} was released before this call`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
...existing,
|
|
83
|
+
state: "released",
|
|
84
|
+
releasedAt: input.at,
|
|
85
|
+
releasedBy: input.actor.subject,
|
|
86
|
+
releaseReason: input.reason,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One hold by id, or null. */
|
|
91
|
+
export async function readHold(db: ControlPlaneDatabase, id: string): Promise<LegalHold | null> {
|
|
92
|
+
const row = await db.selectFrom(CONTROL_PLANE_HOLDS_TABLE).selectAll().where("id", "=", id).executeTakeFirst();
|
|
93
|
+
return row ? LegalHold.parse(row) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every hold, active first, newest first within each, at most `limit`. */
|
|
97
|
+
export async function listHolds(
|
|
98
|
+
db: ControlPlaneDatabase,
|
|
99
|
+
options: { state?: "active" | "released"; limit: number },
|
|
100
|
+
): Promise<LegalHold[]> {
|
|
101
|
+
let query = db
|
|
102
|
+
.selectFrom(CONTROL_PLANE_HOLDS_TABLE)
|
|
103
|
+
.selectAll()
|
|
104
|
+
.orderBy("state", "asc")
|
|
105
|
+
.orderBy("placedAt", "desc")
|
|
106
|
+
.orderBy("id", "asc")
|
|
107
|
+
.limit(options.limit);
|
|
108
|
+
if (options.state) query = query.where("state", "=", options.state);
|
|
109
|
+
return (await query.execute()).map((row) => LegalHold.parse(row));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How many holds stand. What the manifest carries. */
|
|
113
|
+
export async function countActiveHolds(db: ControlPlaneDatabase): Promise<number> {
|
|
114
|
+
const row = await db
|
|
115
|
+
.selectFrom(CONTROL_PLANE_HOLDS_TABLE)
|
|
116
|
+
.select((eb) => eb.fn.countAll<number>().as("n"))
|
|
117
|
+
.where("state", "=", "active")
|
|
118
|
+
.executeTakeFirst();
|
|
119
|
+
return Number(row?.n ?? 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The question every purge, prune and inactivity pass asks before it touches a subject. */
|
|
123
|
+
export interface LegalHolds {
|
|
124
|
+
/** Whether an active hold stands on this subject. A `null` id is the untenanted audit trail. */
|
|
125
|
+
held(subjectType: HoldSubjectType, subjectId: string | null): Promise<boolean>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The app database the control plane keeps its holds in. Fixed, as the seam's own binding is. */
|
|
129
|
+
export const HOLDS_BINDING = "DB";
|
|
130
|
+
|
|
131
|
+
/** Whether a D1 failure is the hold table not existing — a database no control plane ever migrated. */
|
|
132
|
+
function noHoldTable(error: unknown): boolean {
|
|
133
|
+
const text = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error);
|
|
134
|
+
return /no such table/i.test(text) && /pithy_controlplane_holds/i.test(text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The holds over one env, read from {@link HOLDS_BINDING}. See the file header for what an unreadable read does. */
|
|
138
|
+
export function legalHolds(env: Record<string, unknown>): LegalHolds {
|
|
139
|
+
const binding = env[HOLDS_BINDING];
|
|
140
|
+
// No app database at all is a Worker no control plane could ever have placed a hold through.
|
|
141
|
+
if (typeof binding !== "object" || binding === null) return { held: async () => false };
|
|
142
|
+
const db = controlPlaneDatabase(binding as D1Database);
|
|
143
|
+
return {
|
|
144
|
+
held: async (subjectType, subjectId) => {
|
|
145
|
+
try {
|
|
146
|
+
let query = db
|
|
147
|
+
.selectFrom(CONTROL_PLANE_HOLDS_TABLE)
|
|
148
|
+
.select("id")
|
|
149
|
+
.where("state", "=", "active")
|
|
150
|
+
.where("subjectType", "=", subjectType)
|
|
151
|
+
.limit(1);
|
|
152
|
+
query = subjectId === null ? query.where("subjectId", "is", null) : query.where("subjectId", "=", subjectId);
|
|
153
|
+
return (await query.execute()).length > 0;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (noHoldTable(error)) return false;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* How many holds stand in this env's app database — zero where no control plane ever migrated it, since nothing
|
|
164
|
+
* could have placed one there. Any other failure throws, and the manifest reports the summary unavailable.
|
|
165
|
+
*/
|
|
166
|
+
export async function standingHolds(env: Record<string, unknown>): Promise<number> {
|
|
167
|
+
const binding = env[HOLDS_BINDING];
|
|
168
|
+
if (typeof binding !== "object" || binding === null) return 0;
|
|
169
|
+
try {
|
|
170
|
+
return await countActiveHolds(controlPlaneDatabase(binding as D1Database));
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (noHoldTable(error)) return 0;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { personRowsExport } from "../capability/personRows";
|
|
5
|
+
import type { RetainingPurgeContribution } from "../capability/purge";
|
|
6
|
+
import { LegalHold } from "../controlPlane/data/hold";
|
|
7
|
+
import { CONTROL_PLANE_HOLDS_TABLE } from "../controlPlane/data/tables";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a person's purge means for the legal holds: **nothing is erased, and the export says so and why.**
|
|
11
|
+
*
|
|
12
|
+
* A hold names the user it preserves. The row is the record that a hold stood, from when to when and why it ended,
|
|
13
|
+
* and it is kept after release for exactly that reason; a purge the hold allowed to run does not take the record of
|
|
14
|
+
* the hold with it. The export answers for every hold placed on the person.
|
|
15
|
+
*
|
|
16
|
+
* **Only `user` holds are theirs.** One `subjectId` column names users, organizations and audit tenants, and a
|
|
17
|
+
* tenant may be named by a user's id. **And the export says that a hold stood, never who placed it or why**: the
|
|
18
|
+
* operator who placed or lifted it is another person, and the reasons are counsel's words about a matter the person
|
|
19
|
+
* may be a party to.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The one sentence the purge records with this step. */
|
|
23
|
+
export const HOLDS_PURGE_SUMMARY = "Keeps every legal hold placed on the user.";
|
|
24
|
+
|
|
25
|
+
/** Why, in the sentence the person is told. */
|
|
26
|
+
export const HOLDS_RETENTION_BASIS = "Kept as the record that a legal hold stood, when it was placed and why it ended.";
|
|
27
|
+
|
|
28
|
+
/** The user contribution the control plane declares, over the app database its holds live in. */
|
|
29
|
+
export function holdsUserPurge(database = "DB"): RetainingPurgeContribution {
|
|
30
|
+
return {
|
|
31
|
+
subject: "user",
|
|
32
|
+
summary: HOLDS_PURGE_SUMMARY,
|
|
33
|
+
retain: { basis: HOLDS_RETENTION_BASIS },
|
|
34
|
+
export: personRowsExport({
|
|
35
|
+
capability: "controlplane",
|
|
36
|
+
database,
|
|
37
|
+
tables: [
|
|
38
|
+
{
|
|
39
|
+
table: CONTROL_PLANE_HOLDS_TABLE,
|
|
40
|
+
schema: LegalHold,
|
|
41
|
+
columns: ["subjectId"],
|
|
42
|
+
match: { subjectType: "user" },
|
|
43
|
+
as: "holds",
|
|
44
|
+
omit: ["placedBy", "releasedBy", "reason", "releaseReason"],
|
|
45
|
+
orderBy: "placedAt",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fromZodError, ValidationError } from "../error/pithyError";
|
|
6
|
+
import { PrivacyClassName } from "../privacy/classes";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **How long everything is kept, declared in one place.**
|
|
10
|
+
*
|
|
11
|
+
* A Worker's `pithy.config.ts` carries one `retention` block beside its `capabilities`, so the policy is bundled
|
|
12
|
+
* with the code that enforces it and deployed like it. Three things are governed:
|
|
13
|
+
*
|
|
14
|
+
* - **Purge windows.** How long a deleted user or organization stays restorable before its purge. Each capability
|
|
15
|
+
* that owns a deletion keeps its own `retention.purgeAfterDays` as the default; a window stated here replaces it.
|
|
16
|
+
* - **Audit retention.** How long audit events are kept, by action prefix, with a default. Absent means forever.
|
|
17
|
+
* The prune Workflow in `@pithy-sh/audit` removes events past their window and leaves a checkpointed tombstone.
|
|
18
|
+
* - **Class ages.** The longest an account may sit inactive while it holds a privacy class. An account untouched
|
|
19
|
+
* for longer than the strictest class it holds is soft-deleted through the lifecycle that already exists.
|
|
20
|
+
*
|
|
21
|
+
* **The kit holds a floor under the audit trail.** No rule may keep `controlplane/*` or a lifecycle action for
|
|
22
|
+
* less than {@link AUDIT_RETENTION_FLOOR_DAYS} days, and a rule that would is refused when the config loads. The
|
|
23
|
+
* default is not a rule: a short default leaves the floored actions at the floor, and the manifest says so.
|
|
24
|
+
*
|
|
25
|
+
* Nothing here shortens anything at runtime. The policy is code, and no route changes it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** A day, in ms. */
|
|
29
|
+
export const MS_PER_DAY = 86_400_000;
|
|
30
|
+
|
|
31
|
+
/** The least the kit keeps a floored audit action: a year. */
|
|
32
|
+
export const AUDIT_RETENTION_FLOOR_DAYS = 365;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The action prefixes the floor always covers, whatever is composed. The control plane's own trail — every call,
|
|
36
|
+
* every key, every hold — is the record of who managed the Worker, and it outlives any short default.
|
|
37
|
+
*/
|
|
38
|
+
export const FLOORED_AUDIT_PREFIXES = ["controlplane/"] as const;
|
|
39
|
+
|
|
40
|
+
/** Days per unit of a duration string. A month is thirty days and a year is 365: a policy, not a calendar. */
|
|
41
|
+
const UNIT_DAYS = { d: 1, w: 7, m: 30, y: 365 } as const;
|
|
42
|
+
|
|
43
|
+
/** The longest any duration may be: a century. Past that, say `forever`. */
|
|
44
|
+
const MAX_DAYS = 36_500;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A length of time: whole days as a number, or a count and a unit — `90d`, `12w`, `18m`, `3y`. A month is thirty
|
|
48
|
+
* days and a year is 365.
|
|
49
|
+
*/
|
|
50
|
+
export const RetentionDuration = z
|
|
51
|
+
.union([
|
|
52
|
+
z.number().int().min(1).max(MAX_DAYS),
|
|
53
|
+
z.string().regex(/^[1-9]\d{0,4}[dwmy]$/, "A duration is a whole number and a unit: `90d`, `12w`, `18m` or `3y`."),
|
|
54
|
+
])
|
|
55
|
+
.describe(
|
|
56
|
+
"A length of time: whole days as a number, or a count and a unit — `90d`, `12w`, `18m`, `3y`. A month is thirty days and a year is 365.",
|
|
57
|
+
);
|
|
58
|
+
export type RetentionDuration = z.infer<typeof RetentionDuration>;
|
|
59
|
+
|
|
60
|
+
/** A duration in whole days. */
|
|
61
|
+
export function durationDays(value: RetentionDuration): number {
|
|
62
|
+
if (typeof value === "number") return value;
|
|
63
|
+
const unit = value.slice(-1) as keyof typeof UNIT_DAYS;
|
|
64
|
+
return Number(value.slice(0, -1)) * UNIT_DAYS[unit];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An audit action prefix: a domain, a slash, and as much of the reason as the rule means. */
|
|
68
|
+
const AuditActionPrefix = z
|
|
69
|
+
.string()
|
|
70
|
+
.regex(
|
|
71
|
+
/^[a-z][a-z0-9_]*\/[a-z0-9_.]*$/,
|
|
72
|
+
"An audit prefix is a domain and a slash, optionally followed by the start of a reason: `auth/`, `support/thread_`.",
|
|
73
|
+
)
|
|
74
|
+
.describe(
|
|
75
|
+
"An audit action prefix: a domain and a slash, optionally the start of a reason — `auth/`, `support/thread_`. The longest prefix matching an action decides its window.",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** The `retention` block, as a Worker's `pithy.config.ts` writes it. */
|
|
79
|
+
export const RetentionPolicy = z
|
|
80
|
+
.object({
|
|
81
|
+
purgeWindows: z
|
|
82
|
+
.object({
|
|
83
|
+
user: RetentionDuration.optional().describe(
|
|
84
|
+
"How long a deleted account stays restorable before its purge. Replaces `auth({ retention: { purgeAfterDays } })`.",
|
|
85
|
+
),
|
|
86
|
+
organization: RetentionDuration.optional().describe(
|
|
87
|
+
"How long a deleted organization stays restorable before its purge. Replaces `organization({ retention: { purgeAfterDays } })`.",
|
|
88
|
+
),
|
|
89
|
+
})
|
|
90
|
+
.strict()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(
|
|
93
|
+
"Every soft-delete window, in one place. A subject left out keeps the window its own capability configures.",
|
|
94
|
+
),
|
|
95
|
+
audit: z
|
|
96
|
+
.object({
|
|
97
|
+
default: z
|
|
98
|
+
.union([RetentionDuration, z.literal("forever")])
|
|
99
|
+
.optional()
|
|
100
|
+
.describe(
|
|
101
|
+
"How long an audit event no prefix names is kept. Absent, or `forever`, keeps it forever. Never applied under the floor to a floored action.",
|
|
102
|
+
),
|
|
103
|
+
prefixes: z
|
|
104
|
+
.record(AuditActionPrefix, RetentionDuration)
|
|
105
|
+
.optional()
|
|
106
|
+
.describe(
|
|
107
|
+
"How long events are kept by action prefix. The longest matching prefix wins. A rule under 365 days that covers `controlplane/*` or a lifecycle action is refused.",
|
|
108
|
+
),
|
|
109
|
+
})
|
|
110
|
+
.strict()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("How long the audit trail is kept. Absent keeps everything forever."),
|
|
113
|
+
classAges: z
|
|
114
|
+
.record(PrivacyClassName, RetentionDuration)
|
|
115
|
+
.optional()
|
|
116
|
+
.describe(
|
|
117
|
+
'The longest an account may sit inactive — no sign-in, no session — while it holds a privacy class: `{ pii: "3y" }`. Past the strictest class it holds, it is soft-deleted through the lifecycle, with its notice mail and its purge window.',
|
|
118
|
+
),
|
|
119
|
+
})
|
|
120
|
+
.strict()
|
|
121
|
+
.describe("How long this Worker keeps deleted accounts, the audit trail and each class of personal data.");
|
|
122
|
+
export type RetentionPolicy = z.infer<typeof RetentionPolicy>;
|
|
123
|
+
export type RetentionPolicyInput = z.input<typeof RetentionPolicy>;
|
|
124
|
+
|
|
125
|
+
/** Where an effective value came from: this block, a capability's own config, or the kit's floor. */
|
|
126
|
+
export const RetentionSource = z
|
|
127
|
+
.enum(["policy", "capability", "floor"])
|
|
128
|
+
.describe(
|
|
129
|
+
"Where the value came from: `policy`, the `retention` block; `capability`, the capability's own config, because the block names none; `floor`, the kit's minimum for the audit trail.",
|
|
130
|
+
);
|
|
131
|
+
export type RetentionSource = z.infer<typeof RetentionSource>;
|
|
132
|
+
|
|
133
|
+
/** The policy as it takes effect: every value resolved to days, and where each came from. */
|
|
134
|
+
export const EffectiveRetention = z
|
|
135
|
+
.object({
|
|
136
|
+
purgeWindows: z
|
|
137
|
+
.array(
|
|
138
|
+
z
|
|
139
|
+
.object({
|
|
140
|
+
subject: z.enum(["user", "organization"]).describe("Whose deletion the window governs."),
|
|
141
|
+
days: z.number().int().min(0).describe("How many days a deletion stays restorable before its purge."),
|
|
142
|
+
source: RetentionSource,
|
|
143
|
+
})
|
|
144
|
+
.describe("One soft-delete window as it takes effect."),
|
|
145
|
+
)
|
|
146
|
+
.describe("Every soft-delete window a composed capability runs, in composition order."),
|
|
147
|
+
audit: z
|
|
148
|
+
.object({
|
|
149
|
+
defaultDays: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.min(1)
|
|
153
|
+
.nullable()
|
|
154
|
+
.describe("How long an event no rule names is kept, in days. Null keeps it forever."),
|
|
155
|
+
floorDays: z
|
|
156
|
+
.number()
|
|
157
|
+
.int()
|
|
158
|
+
.min(1)
|
|
159
|
+
.describe("The least the kit keeps `controlplane/*` and every lifecycle action, in days."),
|
|
160
|
+
rules: z
|
|
161
|
+
.array(
|
|
162
|
+
z
|
|
163
|
+
.object({
|
|
164
|
+
prefix: z.string().describe("The action prefix, or a floored action in full."),
|
|
165
|
+
days: z.number().int().min(1).describe("How long matching events are kept, in days."),
|
|
166
|
+
source: RetentionSource,
|
|
167
|
+
})
|
|
168
|
+
.describe("One audit rule as it takes effect."),
|
|
169
|
+
)
|
|
170
|
+
.describe(
|
|
171
|
+
"Every rule, longest prefix first. A `floor` rule is the kit holding a floored action at the floor under a shorter default.",
|
|
172
|
+
),
|
|
173
|
+
floored: z
|
|
174
|
+
.array(z.string())
|
|
175
|
+
.describe("Every prefix and action the floor covers: `controlplane/` and each composed lifecycle action."),
|
|
176
|
+
})
|
|
177
|
+
.describe("How long the audit trail is kept."),
|
|
178
|
+
classAges: z
|
|
179
|
+
.array(
|
|
180
|
+
z
|
|
181
|
+
.object({
|
|
182
|
+
class: PrivacyClassName.describe("The privacy class."),
|
|
183
|
+
days: z.number().int().min(1).describe("The longest an account holding it may sit inactive, in days."),
|
|
184
|
+
})
|
|
185
|
+
.describe("One class age."),
|
|
186
|
+
)
|
|
187
|
+
.describe("Every class age the block declares, strictest first."),
|
|
188
|
+
})
|
|
189
|
+
.describe("This Worker's retention, as it takes effect: every window in days, and where each came from.");
|
|
190
|
+
export type EffectiveRetention = z.infer<typeof EffectiveRetention>;
|
|
191
|
+
|
|
192
|
+
/** A capability's own soft-delete window, the default a policy may replace. */
|
|
193
|
+
export interface PurgeWindowDefault {
|
|
194
|
+
readonly subject: "user" | "organization";
|
|
195
|
+
readonly days: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What resolving needs from the composition: each capability's window, and the lifecycle actions it audits. */
|
|
199
|
+
export interface RetentionComposition {
|
|
200
|
+
/** Each composed capability's own soft-delete window, in composition order. */
|
|
201
|
+
readonly purgeWindows: readonly PurgeWindowDefault[];
|
|
202
|
+
/** Every lifecycle audit action the composed capabilities record — the floor covers each. */
|
|
203
|
+
readonly lifecycleActions: readonly string[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether a rule's prefix would decide the window of a floored prefix or action. */
|
|
207
|
+
function covers(prefix: string, floored: string): boolean {
|
|
208
|
+
return floored.startsWith(prefix) || prefix.startsWith(floored);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse a `retention` block, or refuse it naming what is wrong. `undefined` is the empty policy: every capability's
|
|
213
|
+
* own window, an audit trail kept forever, no class ages.
|
|
214
|
+
*/
|
|
215
|
+
export function parseRetentionPolicy(input: unknown): RetentionPolicy {
|
|
216
|
+
if (input === undefined) return {};
|
|
217
|
+
const parsed = RetentionPolicy.safeParse(input);
|
|
218
|
+
if (!parsed.success) {
|
|
219
|
+
throw fromZodError(parsed.error, {
|
|
220
|
+
message: "The retention block in pithy.config.ts does not parse.",
|
|
221
|
+
action: "Fix the field named, then deploy again.",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return parsed.data;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The policy as it takes effect over a composition — or refused, when a rule would keep a floored action for less
|
|
229
|
+
* than the floor. Called when a Worker assembles, so a policy that would prune the control plane's trail early never
|
|
230
|
+
* deploys.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveRetention(policy: RetentionPolicy, composition: RetentionComposition): EffectiveRetention {
|
|
233
|
+
const floored = [...FLOORED_AUDIT_PREFIXES, ...new Set(composition.lifecycleActions)];
|
|
234
|
+
const rules = Object.entries(policy.audit?.prefixes ?? {}).map(([prefix, duration]) => ({
|
|
235
|
+
prefix,
|
|
236
|
+
days: durationDays(duration),
|
|
237
|
+
source: "policy" as RetentionSource,
|
|
238
|
+
}));
|
|
239
|
+
for (const rule of rules) {
|
|
240
|
+
if (rule.days >= AUDIT_RETENTION_FLOOR_DAYS) continue;
|
|
241
|
+
const hit = floored.find((each) => covers(rule.prefix, each));
|
|
242
|
+
if (hit !== undefined) {
|
|
243
|
+
throw new ValidationError({
|
|
244
|
+
message: `The retention block keeps ${rule.prefix} for ${rule.days} days, under the kit's floor of ${AUDIT_RETENTION_FLOOR_DAYS} for ${hit}.`,
|
|
245
|
+
action: `Keep it at least ${AUDIT_RETENTION_FLOOR_DAYS} days, or name a narrower prefix that leaves ${hit} out.`,
|
|
246
|
+
detail: `audit retention rule ${rule.prefix}=${rule.days}d covers floored ${hit}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const fallback = policy.audit?.default;
|
|
251
|
+
const defaultDays = fallback === undefined || fallback === "forever" ? null : durationDays(fallback);
|
|
252
|
+
// Under a default shorter than the floor, every floored action no rule already decides is held at the floor.
|
|
253
|
+
if (defaultDays !== null && defaultDays < AUDIT_RETENTION_FLOOR_DAYS) {
|
|
254
|
+
for (const each of floored) {
|
|
255
|
+
const decided = rules.some((rule) => each.startsWith(rule.prefix));
|
|
256
|
+
if (!decided) rules.push({ prefix: each, days: AUDIT_RETENTION_FLOOR_DAYS, source: "floor" });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
rules.sort((a, b) => b.prefix.length - a.prefix.length || a.prefix.localeCompare(b.prefix));
|
|
260
|
+
|
|
261
|
+
const windows = policy.purgeWindows ?? {};
|
|
262
|
+
const purgeWindows = composition.purgeWindows.map(({ subject, days }) => {
|
|
263
|
+
const stated = windows[subject];
|
|
264
|
+
return stated === undefined
|
|
265
|
+
? { subject, days, source: "capability" as RetentionSource }
|
|
266
|
+
: { subject, days: durationDays(stated), source: "policy" as RetentionSource };
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const classAges = Object.entries(policy.classAges ?? {})
|
|
270
|
+
.map(([name, duration]) => ({ class: name, days: durationDays(duration) }))
|
|
271
|
+
.sort((a, b) => a.days - b.days || a.class.localeCompare(b.class));
|
|
272
|
+
|
|
273
|
+
return { purgeWindows, audit: { defaultDays, floorDays: AUDIT_RETENTION_FLOOR_DAYS, rules, floored }, classAges };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* How many days an audit event with this action is kept, or `null` for forever. The longest matching rule decides;
|
|
278
|
+
* with none, the default. A floored action is never kept under the floor, whichever decided.
|
|
279
|
+
*/
|
|
280
|
+
export function auditWindowDays(retention: EffectiveRetention, action: string): number | null {
|
|
281
|
+
const rule = retention.audit.rules.find((each) => action.startsWith(each.prefix));
|
|
282
|
+
const days = rule?.days ?? retention.audit.defaultDays;
|
|
283
|
+
if (days === null) return null;
|
|
284
|
+
const floored = retention.audit.floored.some((each) => action.startsWith(each));
|
|
285
|
+
return floored ? Math.max(days, retention.audit.floorDays) : days;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Whether the policy prunes any audit event at all. */
|
|
289
|
+
export function prunesAudit(retention: EffectiveRetention): boolean {
|
|
290
|
+
return retention.audit.defaultDays !== null || retention.audit.rules.some((rule) => rule.source === "policy");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The soft-delete window for a subject, in days, or `undefined` when no composed capability runs one. */
|
|
294
|
+
export function purgeWindowDays(retention: EffectiveRetention, subject: "user" | "organization"): number | undefined {
|
|
295
|
+
return retention.purgeWindows.find((each) => each.subject === subject)?.days;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The longest an account may sit inactive given the classes it holds, in days — the strictest class age among
|
|
300
|
+
* them — or `null` when no class it holds has an age.
|
|
301
|
+
*/
|
|
302
|
+
export function inactivityDays(retention: EffectiveRetention, held: readonly string[]): number | null {
|
|
303
|
+
const ages = retention.classAges.filter((age) => held.includes(age.class)).map((age) => age.days);
|
|
304
|
+
return ages.length === 0 ? null : Math.min(...ages);
|
|
305
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One session replaced by its successor: a refresh-credential rotation. The capability that owns sessions
|
|
8
|
+
* announces it once the presented session is spent and the successor stands, so a capability that keyed
|
|
9
|
+
* state on the old id can carry it to the new one.
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionRotation {
|
|
12
|
+
/** The session that was presented and is now gone. */
|
|
13
|
+
readonly previousSessionId: string;
|
|
14
|
+
/** The session that replaces it. */
|
|
15
|
+
readonly sessionId: string;
|
|
16
|
+
/** Whose sessions both are. A listener matches on it as well as the id. */
|
|
17
|
+
readonly userId: string;
|
|
18
|
+
/** The Worker's bindings for this request. */
|
|
19
|
+
readonly env: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hear of a rotation. Called after the successor exists and the presented session is gone; a throw is the
|
|
24
|
+
* listener's own failure, and the rotation it reports has already happened.
|
|
25
|
+
*/
|
|
26
|
+
export type SessionRotatedListener = (rotation: SessionRotation) => Promise<void>;
|
|
27
|
+
|
|
28
|
+
/** A listener that threw, named by its capability. */
|
|
29
|
+
export interface SessionRotatedFailure {
|
|
30
|
+
readonly capability: string;
|
|
31
|
+
readonly error: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tell every composed capability that declares {@link Capability.sessionRotated}, in composition order. Each
|
|
36
|
+
* listener runs whatever the one before it did; the failures come back for the caller to log.
|
|
37
|
+
*/
|
|
38
|
+
export async function announceSessionRotated(
|
|
39
|
+
capabilities: readonly Capability[],
|
|
40
|
+
rotation: SessionRotation,
|
|
41
|
+
): Promise<SessionRotatedFailure[]> {
|
|
42
|
+
const failures: SessionRotatedFailure[] = [];
|
|
43
|
+
for (const capability of capabilities) {
|
|
44
|
+
const listener = capability.sessionRotated;
|
|
45
|
+
if (!listener) continue;
|
|
46
|
+
try {
|
|
47
|
+
await listener(rotation);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
failures.push({ capability: capability.name, error });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return failures;
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ControlPlaneConnection, RegisteredKey } from "../controlPlane/data/connection";
|
|
5
|
+
import type { OidcTrust } from "../controlPlane/data/oidcTrust";
|
|
6
|
+
|
|
7
|
+
/** The instant the connection fixtures are read at. */
|
|
8
|
+
export const FIXTURE_NOW = new Date("2026-10-27T12:00:00.000Z");
|
|
9
|
+
const REGISTERED = new Date("2026-10-01T00:00:00.000Z");
|
|
10
|
+
|
|
11
|
+
/** A registered key, live at {@link FIXTURE_NOW} unless overridden. */
|
|
12
|
+
export function fixtureKey(keyId: string, overrides: Partial<RegisteredKey> = {}): RegisteredKey {
|
|
13
|
+
return {
|
|
14
|
+
keyId,
|
|
15
|
+
publicKey: { kty: "OKP", crv: "Ed25519", x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" },
|
|
16
|
+
validFrom: REGISTERED,
|
|
17
|
+
validUntil: null,
|
|
18
|
+
revokedAt: null,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An OIDC trust, live at {@link FIXTURE_NOW} unless overridden. */
|
|
24
|
+
export function fixtureTrust(trustId: string, overrides: Partial<OidcTrust> = {}): OidcTrust {
|
|
25
|
+
return {
|
|
26
|
+
trustId,
|
|
27
|
+
issuer: "https://token.actions.githubusercontent.com",
|
|
28
|
+
jwksUrl: "https://token.actions.githubusercontent.com/.well-known/jwks",
|
|
29
|
+
audience: "https://api.example.com",
|
|
30
|
+
claims: { repository_id: "1262274726" },
|
|
31
|
+
authorizedBy: "ada@example.com",
|
|
32
|
+
validFrom: REGISTERED,
|
|
33
|
+
revokedAt: null,
|
|
34
|
+
firstCallAt: null,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A connection authorized by ada@example.com, holding one live key, unless overridden. */
|
|
40
|
+
export function fixtureConnection(overrides: Partial<ControlPlaneConnection> = {}): ControlPlaneConnection {
|
|
41
|
+
return {
|
|
42
|
+
id: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
43
|
+
environment: "prod",
|
|
44
|
+
issuer: "https://app.pithy.sh",
|
|
45
|
+
workerUrl: "https://api.example.com",
|
|
46
|
+
basePath: "/control-plane",
|
|
47
|
+
managementOrigin: null,
|
|
48
|
+
authorizedBy: "ada@example.com",
|
|
49
|
+
actorKind: "agent",
|
|
50
|
+
purpose: "Nightly audit summarizer.",
|
|
51
|
+
scopes: ["manifest:read"],
|
|
52
|
+
keys: [fixtureKey("k1")],
|
|
53
|
+
trusts: [],
|
|
54
|
+
createdAt: REGISTERED,
|
|
55
|
+
updatedAt: REGISTERED,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely, KyselyPlugin, QueryId } from "kysely";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The same database, with `between` run after each of its first `times` reads and before the caller sees the rows: a
|
|
8
|
+
* write that lands between a read and the write made from it.
|
|
9
|
+
*
|
|
10
|
+
* `between` receives the count of reads it has followed so far, from 0. It must write through another handle, since a
|
|
11
|
+
* read it made through this one would be followed in turn.
|
|
12
|
+
*/
|
|
13
|
+
export function writeAfterRead<DB>(
|
|
14
|
+
db: Kysely<DB>,
|
|
15
|
+
between: (count: number) => Promise<void>,
|
|
16
|
+
times = Number.POSITIVE_INFINITY,
|
|
17
|
+
): Kysely<DB> {
|
|
18
|
+
const reads = new WeakSet<QueryId>();
|
|
19
|
+
let count = 0;
|
|
20
|
+
const plugin: KyselyPlugin = {
|
|
21
|
+
transformQuery({ queryId, node }) {
|
|
22
|
+
if (node.kind === "SelectQueryNode") reads.add(queryId);
|
|
23
|
+
return node;
|
|
24
|
+
},
|
|
25
|
+
async transformResult({ queryId, result }) {
|
|
26
|
+
if (reads.has(queryId) && count < times) await between(count++);
|
|
27
|
+
return result;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return db.withPlugin(plugin);
|
|
31
|
+
}
|