@pithy-sh/core 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/composed.d.ts +18 -0
- package/dist/audit/composed.d.ts.map +1 -0
- package/dist/audit/composed.js +28 -0
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/capability.d.ts +23 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/purge.d.ts +122 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +131 -0
- package/dist/cli/commandCatalog.generated.d.ts +54 -2
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +61 -0
- package/dist/cli/commandFacts.d.ts +1 -0
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +7 -0
- package/dist/controlPlane/data/connection.d.ts +2 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +1 -1
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +40 -1
- package/dist/controlPlane/http/handlers.d.ts +79 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +37 -3
- package/dist/controlPlane/http/responses.d.ts +98 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +41 -1
- package/dist/controlPlane/http/routes.d.ts +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -3
- package/dist/controlPlane/scope/scope.d.ts +13 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -4
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +2 -0
- package/dist/createEntrypoint.d.ts +7 -1
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +15 -5
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +72 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +144 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +48 -0
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- package/dist/http/validation.d.ts +11 -2
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +20 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +42 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/src/audit/composed.ts +30 -0
- package/src/audit/recorder.ts +10 -2
- package/src/capability/capability.ts +26 -0
- package/src/capability/purge.ts +187 -0
- package/src/cli/commandCatalog.generated.ts +65 -0
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +11 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/guard.ts +60 -1
- package/src/controlPlane/http/handlers.ts +57 -4
- package/src/controlPlane/http/responses.ts +100 -0
- package/src/controlPlane/http/routes.ts +31 -1
- package/src/controlPlane/scope/scope.ts +14 -3
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +4 -0
- package/src/createEntrypoint.ts +17 -2
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +110 -0
- package/src/error/pithyError.ts +21 -7
- package/src/http/validation.ts +35 -3
- package/src/index.ts +1 -0
- package/src/logger/logger.ts +12 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/version.generated.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
import { type AuditEmit, noopEmit } from "./recorder";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The audit recorder for code with no request — a Workflow step (#622).
|
|
9
|
+
*
|
|
10
|
+
* A route records through `c.var.emit`. A Workflow has no `c`: the runtime constructs it with `env` and nothing
|
|
11
|
+
* else. So it finds the composed capability that declares {@link Capability.auditRecorder} — `@pithy-sh/audit`
|
|
12
|
+
* — and records through that, built over this run's env. With none composed it is {@link noopEmit}, exactly as
|
|
13
|
+
* `c.var.emit` is in a Worker that composes no audit, so a step can always record without asking first.
|
|
14
|
+
*
|
|
15
|
+
* **Non-fatal, whatever the recorder does.** The contract says a recorder never throws; this holds a recorder
|
|
16
|
+
* to it rather than trusting it, because what a step records has already happened and a throw here would turn
|
|
17
|
+
* it into a retried step.
|
|
18
|
+
*/
|
|
19
|
+
export function composedAuditEmit(capabilities: readonly Capability[], env: Record<string, unknown>): AuditEmit {
|
|
20
|
+
const provider = capabilities.find((capability) => capability.auditRecorder !== undefined);
|
|
21
|
+
if (!provider?.auditRecorder) return noopEmit;
|
|
22
|
+
const record = provider.auditRecorder(env);
|
|
23
|
+
return async (event) => {
|
|
24
|
+
try {
|
|
25
|
+
await record(event);
|
|
26
|
+
} catch {
|
|
27
|
+
// The recorder broke its own contract. The event is lost, and the step it recorded stands.
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/audit/recorder.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { Loggable } from "../privacy/types";
|
|
5
|
+
import type { AuditEventInput, AuditMetadata } from "./auditEvent";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The recorder seam. `emit()` on the request context (`c.var.emit`) takes one {@link AuditEventInput}
|
|
@@ -12,8 +13,15 @@ import type { AuditEventInput } from "./auditEvent";
|
|
|
12
13
|
* `emit()` is **non-fatal by contract**: it never throws and never rejects, so an audited action is
|
|
13
14
|
* never broken by an audit write. The real recorder swallows and logs its own failures; the no-op
|
|
14
15
|
* simply resolves.
|
|
16
|
+
*
|
|
17
|
+
* **`metadata` refuses a value marked with a privacy class** (#132). The trail is the longest-lived internal
|
|
18
|
+
* surface there is — a row in the adopter's D1 with no retention window — so a `pii()`/`phi()`/`secret()`/
|
|
19
|
+
* `pci()` value in it, or inside an object in it, is a compile error, by the same `Loggable` rule the logger
|
|
20
|
+
* applies. `AuditMetadata` the schema stays permissive: a stored row still parses.
|
|
15
21
|
*/
|
|
16
|
-
export type AuditEmit =
|
|
22
|
+
export type AuditEmit = <M extends AuditMetadata = AuditMetadata>(
|
|
23
|
+
event: Omit<AuditEventInput, "metadata"> & { metadata?: (M & Loggable<M>) | null | undefined },
|
|
24
|
+
) => Promise<void>;
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* The default recorder when no audit capability is composed: accept the event and drop it. Resolves
|
|
@@ -26,6 +26,7 @@ import type { SeedSet } from "../seed/seed";
|
|
|
26
26
|
import type { WorkflowSpecMap } from "../workflow/spec";
|
|
27
27
|
import { BindingSpec, type BindingSpecInput } from "./bindings";
|
|
28
28
|
import type { ClientProjection, ClientProjectionContext } from "./client";
|
|
29
|
+
import { assertPurgeContributions, type PurgeContribution } from "./purge";
|
|
29
30
|
import type { CapabilitySettings } from "./settings";
|
|
30
31
|
|
|
31
32
|
/** Hono `Variables` every capability's routes are typed against. `createBackend` seeds these per request. */
|
|
@@ -632,6 +633,17 @@ export interface Capability<
|
|
|
632
633
|
* capability that declares one (e.g. `@pithy-sh/email`'s bounce/complaint handler).
|
|
633
634
|
*/
|
|
634
635
|
email?: CapabilityEmailHandler;
|
|
636
|
+
/**
|
|
637
|
+
* The audit recorder this capability provides **for code that has no request** — a Workflow step, which the
|
|
638
|
+
* runtime constructs with `env` and nothing else (#622). `@pithy-sh/audit` declares it; nothing else does.
|
|
639
|
+
*
|
|
640
|
+
* A route records through `c.var.emit`, which the same capability's middleware fills, and keeps doing that.
|
|
641
|
+
* This is the other door, for the purge that erases a deleted account: it runs in a Workflow and still owes
|
|
642
|
+
* the trail its event. `../audit/composed.ts` finds it among the composed capabilities, so a capability that
|
|
643
|
+
* audits from a step imports no audit package — principle 4, the same reason `c.var.emit` is a seam. Built
|
|
644
|
+
* from the env per call, like the request recorder is built per request. Non-fatal by the same contract.
|
|
645
|
+
*/
|
|
646
|
+
auditRecorder?: (env: Record<string, unknown>) => AuditEmit;
|
|
635
647
|
/**
|
|
636
648
|
* Named D1 databases this capability contributes tables to (database name → {@link DatabaseSpec}).
|
|
637
649
|
* `createBackend` merges every capability's slices per database (via `composeDatabases`) and serves
|
|
@@ -658,6 +670,17 @@ export interface Capability<
|
|
|
658
670
|
* time, and read by `pithy doctor`.
|
|
659
671
|
*/
|
|
660
672
|
extensions?: readonly CapabilityExtension[];
|
|
673
|
+
/**
|
|
674
|
+
* How this capability erases one subject's rows when that subject is purged — see `./purge.ts`. Omit it
|
|
675
|
+
* when this capability holds nothing keyed to a person, which is the ordinary case.
|
|
676
|
+
*
|
|
677
|
+
* **The purge Workflow runs each composed contribution as its own step**, named for this capability, so a
|
|
678
|
+
* failure retries this capability alone and the steps that finished are not re-run. That is why there is
|
|
679
|
+
* at most one per subject here, and why `erase` must succeed when there is nothing left to erase.
|
|
680
|
+
* `defineCapability` refuses a contribution for a subject the kit does not know. An adopter declares one
|
|
681
|
+
* on their own `app` capability for their own tables, with the same types.
|
|
682
|
+
*/
|
|
683
|
+
purge?: readonly PurgeContribution[];
|
|
661
684
|
/**
|
|
662
685
|
* The role powers this capability's own routes gate on, through the acting-organization seam —
|
|
663
686
|
* `media:manage`, `storage:manage`. Omit it when a capability asks no power, which is the normal case.
|
|
@@ -700,6 +723,9 @@ export function defineCapability<
|
|
|
700
723
|
for (const spec of Object.values(input.databases ?? {})) {
|
|
701
724
|
if (spec.migrations && spec.retained) declareRetained(spec.migrations, spec.retained);
|
|
702
725
|
}
|
|
726
|
+
// A purge contribution no purge could run — a subject the kit does not know, or two for one — is refused
|
|
727
|
+
// where it is written, naming the capability, rather than on the day somebody is erased.
|
|
728
|
+
if (input.purge) assertPurgeContributions(input.name, input.purge);
|
|
703
729
|
return {
|
|
704
730
|
...input,
|
|
705
731
|
requiredBindings: input.requiredBindings.map((binding) => BindingSpec.parse(binding)),
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ValidationError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How a capability says what erasing one subject means for its own rows — the purge contribution.
|
|
9
|
+
*
|
|
10
|
+
* ## Why a contribution, and not a cascade
|
|
11
|
+
*
|
|
12
|
+
* A hard delete has to reach rows that `@pithy-sh/media`, `@pithy-sh/support`, `@pithy-sh/testers` and the
|
|
13
|
+
* adopter's own tables hold, and no foreign key reaches across capabilities: each owns its tables, and none
|
|
14
|
+
* references another's. So the only party that knows how one person's rows are erased — deleted outright,
|
|
15
|
+
* or anonymized because a ledger must still add up — is the capability that owns them. It says so here,
|
|
16
|
+
* the way it states its migrations and its seeds, and the purge runs what was composed.
|
|
17
|
+
*
|
|
18
|
+
* ## One capability, one step
|
|
19
|
+
*
|
|
20
|
+
* The purge Workflow (#622) runs every composed contribution as **its own step**, named for the
|
|
21
|
+
* capability. A step that fails retries alone, and the ones that finished are not re-run — which is why a
|
|
22
|
+
* capability may declare at most one contribution per subject, and why every `erase` must be idempotent:
|
|
23
|
+
* a step is retried after a failure that may have come after its rows were already gone, and erasing
|
|
24
|
+
* nothing must then succeed.
|
|
25
|
+
*
|
|
26
|
+
* ## The same contribution answers an export (#622)
|
|
27
|
+
*
|
|
28
|
+
* An access request arrives before an erasure, not after. So a contribution may also say what this capability
|
|
29
|
+
* holds about the subject, on the read side: `export` answers one JSON object, and the control plane's export
|
|
30
|
+
* route assembles one document from every composed contribution, in the same order the purge runs them. A
|
|
31
|
+
* contribution with no `export` is listed as holding rows it did not say how to export, which is the honest
|
|
32
|
+
* answer and the one an operator can act on.
|
|
33
|
+
*
|
|
34
|
+
* ## An adopter declares one too
|
|
35
|
+
*
|
|
36
|
+
* On their own `app` capability, for their own tables, with the same types. The contract is exported for
|
|
37
|
+
* exactly that, and nothing distinguishes a kit contribution from theirs.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The kinds of subject a purge erases. Closed, so a typo is refused where it is written rather than becoming
|
|
42
|
+
* a contribution no purge ever asks for. `organization` joined with organization state (#623): a capability
|
|
43
|
+
* holding rows scoped to a tenant declares one for it, and the organization purge runs it.
|
|
44
|
+
*/
|
|
45
|
+
export const PurgeSubject = z
|
|
46
|
+
.enum(["user", "organization"])
|
|
47
|
+
.describe(
|
|
48
|
+
"The kind of subject a purge erases. `user` is one person's account in `@pithy-sh/auth`, identified by its user id. `organization` is one tenant in `@pithy-sh/organization`, identified by its organization id.",
|
|
49
|
+
);
|
|
50
|
+
export type PurgeSubject = z.output<typeof PurgeSubject>;
|
|
51
|
+
|
|
52
|
+
/** What one step is handed: who is being erased, and this run's bindings. */
|
|
53
|
+
export interface PurgeContext {
|
|
54
|
+
/** The subject being erased — its kind, and its id in that kind's own table. */
|
|
55
|
+
readonly subject: { readonly type: PurgeSubject; readonly id: string };
|
|
56
|
+
/** The Worker's bindings for this run, from which the contribution opens its own database. */
|
|
57
|
+
readonly env: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One capability's answer to "how are this subject's rows erased". */
|
|
61
|
+
export interface PurgeContribution {
|
|
62
|
+
/** The kind of subject this erases. */
|
|
63
|
+
readonly subject: PurgeSubject;
|
|
64
|
+
/**
|
|
65
|
+
* One sentence on what is erased or anonymized — `Deletes the user's uploads and their stored bytes.`
|
|
66
|
+
* Recorded with the step that ran it, so the trail says what a purge did and not only that it ran.
|
|
67
|
+
*/
|
|
68
|
+
readonly summary: string;
|
|
69
|
+
/**
|
|
70
|
+
* Erase or anonymize this subject's rows. **Idempotent**: run again after it succeeded, or after a failure
|
|
71
|
+
* that came once the rows were gone, it must succeed having found nothing to do. Throwing fails this step
|
|
72
|
+
* and only this step.
|
|
73
|
+
*/
|
|
74
|
+
readonly erase: (context: PurgeContext) => Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* What this capability holds about the subject, as one JSON object — the read side of `erase` (#622). It
|
|
77
|
+
* answers an access request, so it carries the subject's own data and never a credential: no session token,
|
|
78
|
+
* no provider token, no push token. Optional: a contribution without one is listed in the export as rows it
|
|
79
|
+
* erases and did not say how to export. A throw costs this capability's entry, never the document.
|
|
80
|
+
*/
|
|
81
|
+
readonly export?: (context: PurgeContext) => Promise<Record<string, unknown>>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One step of a purge: which capability it runs, and the contribution that capability declared. */
|
|
85
|
+
export interface PurgeStep {
|
|
86
|
+
/** The step's name — the capability's, so a retried step names who failed. */
|
|
87
|
+
readonly step: string;
|
|
88
|
+
/** The contribution this step runs. */
|
|
89
|
+
readonly contribution: PurgeContribution;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Refuse a capability's purge contributions that no purge could run as declared: an unknown subject, two for
|
|
94
|
+
* one subject, or one with nothing to record. Called by `defineCapability`, so the refusal names the capability
|
|
95
|
+
* where it is written rather than surfacing in a Workflow on the day somebody is deleted.
|
|
96
|
+
*/
|
|
97
|
+
export function assertPurgeContributions(capability: string, contributions: readonly PurgeContribution[]): void {
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
for (const contribution of contributions) {
|
|
100
|
+
const subject = PurgeSubject.safeParse(contribution.subject);
|
|
101
|
+
if (!subject.success) {
|
|
102
|
+
throw new ValidationError({
|
|
103
|
+
message: `The ${capability} capability declares a purge for "${String(contribution.subject)}", which is not a subject a purge erases.`,
|
|
104
|
+
action: `Declare it for one of: ${PurgeSubject.options.join(", ")}.`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (seen.has(subject.data)) {
|
|
108
|
+
throw new ValidationError({
|
|
109
|
+
message: `The ${capability} capability declares two purge contributions for ${subject.data}.`,
|
|
110
|
+
action: "Fold them into one: each capability is one step of the purge, and a retried step re-runs all of it.",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
seen.add(subject.data);
|
|
114
|
+
if (contribution.summary.trim() === "") {
|
|
115
|
+
throw new ValidationError({
|
|
116
|
+
message: `The ${capability} capability's ${subject.data} purge has no summary.`,
|
|
117
|
+
action: "Say in one sentence what it erases. The purge records it with the step.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (contribution.export !== undefined && typeof contribution.export !== "function") {
|
|
121
|
+
throw new ValidationError({
|
|
122
|
+
message: `The ${capability} capability's ${subject.data} purge has an export that is not a function.`,
|
|
123
|
+
action: "Give it an `export` that answers what this capability holds about the subject, or leave it out.",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (typeof contribution.erase !== "function") {
|
|
127
|
+
throw new ValidationError({
|
|
128
|
+
message: `The ${capability} capability's ${subject.data} purge has no erase function.`,
|
|
129
|
+
action: "Give it an `erase` that removes or anonymizes the subject's rows.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The steps a purge of `subject` runs, in composition order — libraries first, the adopter's `app` last. */
|
|
136
|
+
export function purgeSteps(
|
|
137
|
+
capabilities: readonly { readonly name: string; readonly purge?: readonly PurgeContribution[] }[],
|
|
138
|
+
subject: PurgeSubject,
|
|
139
|
+
): PurgeStep[] {
|
|
140
|
+
const steps: PurgeStep[] = [];
|
|
141
|
+
for (const capability of capabilities) {
|
|
142
|
+
const contribution = capability.purge?.find((candidate) => candidate.subject === subject);
|
|
143
|
+
if (contribution) steps.push({ step: capability.name, contribution });
|
|
144
|
+
}
|
|
145
|
+
return steps;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What one step's export answered: its data, that it declared none, or that it could not be read. */
|
|
149
|
+
export type PurgeExportEntry =
|
|
150
|
+
| {
|
|
151
|
+
readonly capability: string;
|
|
152
|
+
readonly summary: string;
|
|
153
|
+
readonly state: "exported";
|
|
154
|
+
readonly data: Record<string, unknown>;
|
|
155
|
+
}
|
|
156
|
+
| { readonly capability: string; readonly summary: string; readonly state: "not_exported" }
|
|
157
|
+
| { readonly capability: string; readonly summary: string; readonly state: "unavailable" };
|
|
158
|
+
|
|
159
|
+
/** Whether an export answered what the contract promises: one plain object. */
|
|
160
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
161
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read every step's export for one subject — the export document's body, in step order (#622).
|
|
166
|
+
*
|
|
167
|
+
* **Each read is guarded on its own and they run concurrently**, the shape the user pane's sub-reads have
|
|
168
|
+
* (#380): one capability whose table will not read costs its own entry as `unavailable`, and the rest of the
|
|
169
|
+
* document still answers the request. What the read threw is not carried — it names a query and a table, and
|
|
170
|
+
* the document crosses a trust boundary. An answer that is not one object is `unavailable` too, because the
|
|
171
|
+
* document promises an object per capability and a client validates it.
|
|
172
|
+
*/
|
|
173
|
+
export async function purgeExports(steps: readonly PurgeStep[], context: PurgeContext): Promise<PurgeExportEntry[]> {
|
|
174
|
+
return Promise.all(
|
|
175
|
+
steps.map(async ({ step, contribution }): Promise<PurgeExportEntry> => {
|
|
176
|
+
const base = { capability: step, summary: contribution.summary };
|
|
177
|
+
const read = contribution.export;
|
|
178
|
+
if (read === undefined) return { ...base, state: "not_exported" };
|
|
179
|
+
try {
|
|
180
|
+
const data = await read(context);
|
|
181
|
+
return isRecord(data) ? { ...base, state: "exported", data } : { ...base, state: "unavailable" };
|
|
182
|
+
} catch {
|
|
183
|
+
return { ...base, state: "unavailable" };
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -899,6 +899,16 @@ export const COMMANDS = {
|
|
|
899
899
|
"invite",
|
|
900
900
|
"testerNudge",
|
|
901
901
|
"passwordChanged",
|
|
902
|
+
"accountSuspended",
|
|
903
|
+
"accountReinstated",
|
|
904
|
+
"deletionScheduled",
|
|
905
|
+
"deletionComplete",
|
|
906
|
+
"organizationMemberAdded",
|
|
907
|
+
"organizationMemberRemoved",
|
|
908
|
+
"organizationOwnershipTransferred",
|
|
909
|
+
"organizationSuspended",
|
|
910
|
+
"organizationReinstated",
|
|
911
|
+
"organizationDeletionScheduled",
|
|
902
912
|
"operationalNotice",
|
|
903
913
|
"newsletter",
|
|
904
914
|
"leadCapture",
|
|
@@ -1230,6 +1240,33 @@ export const COMMANDS = {
|
|
|
1230
1240
|
},
|
|
1231
1241
|
"groups": []
|
|
1232
1242
|
},
|
|
1243
|
+
"privacy enable": {
|
|
1244
|
+
"positionals": [],
|
|
1245
|
+
"flags": {
|
|
1246
|
+
"--class": {
|
|
1247
|
+
"type": "string",
|
|
1248
|
+
"required": false,
|
|
1249
|
+
"repeatable": true
|
|
1250
|
+
},
|
|
1251
|
+
"--json": {
|
|
1252
|
+
"type": "boolean",
|
|
1253
|
+
"required": false,
|
|
1254
|
+
"repeatable": false
|
|
1255
|
+
}
|
|
1256
|
+
},
|
|
1257
|
+
"groups": []
|
|
1258
|
+
},
|
|
1259
|
+
"privacy scan": {
|
|
1260
|
+
"positionals": [],
|
|
1261
|
+
"flags": {
|
|
1262
|
+
"--json": {
|
|
1263
|
+
"type": "boolean",
|
|
1264
|
+
"required": false,
|
|
1265
|
+
"repeatable": false
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
"groups": []
|
|
1269
|
+
},
|
|
1233
1270
|
"provision": {
|
|
1234
1271
|
"positionals": [],
|
|
1235
1272
|
"flags": {
|
|
@@ -1433,6 +1470,33 @@ export const COMMANDS = {
|
|
|
1433
1470
|
},
|
|
1434
1471
|
"groups": []
|
|
1435
1472
|
},
|
|
1473
|
+
"secrets retire-version": {
|
|
1474
|
+
"positionals": [
|
|
1475
|
+
{
|
|
1476
|
+
"name": "version",
|
|
1477
|
+
"required": true,
|
|
1478
|
+
"variadic": false
|
|
1479
|
+
}
|
|
1480
|
+
],
|
|
1481
|
+
"flags": {
|
|
1482
|
+
"--env": {
|
|
1483
|
+
"type": "string",
|
|
1484
|
+
"required": false,
|
|
1485
|
+
"repeatable": false
|
|
1486
|
+
},
|
|
1487
|
+
"--past-guard": {
|
|
1488
|
+
"type": "boolean",
|
|
1489
|
+
"required": false,
|
|
1490
|
+
"repeatable": false
|
|
1491
|
+
},
|
|
1492
|
+
"--json": {
|
|
1493
|
+
"type": "boolean",
|
|
1494
|
+
"required": false,
|
|
1495
|
+
"repeatable": false
|
|
1496
|
+
}
|
|
1497
|
+
},
|
|
1498
|
+
"groups": []
|
|
1499
|
+
},
|
|
1436
1500
|
"secrets rm": {
|
|
1437
1501
|
"positionals": [
|
|
1438
1502
|
{
|
|
@@ -2711,6 +2775,7 @@ export const GROUPS = [
|
|
|
2711
2775
|
"feature",
|
|
2712
2776
|
"media",
|
|
2713
2777
|
"payments",
|
|
2778
|
+
"privacy",
|
|
2714
2779
|
"secrets",
|
|
2715
2780
|
"storage",
|
|
2716
2781
|
"support",
|
package/src/cli/commandFacts.ts
CHANGED
|
@@ -105,6 +105,7 @@ export const DESTRUCTIVE_COMMANDS = {
|
|
|
105
105
|
"media deprovision": "the media Worker, and with --storage its bucket",
|
|
106
106
|
remove: "a capability's wiring",
|
|
107
107
|
"secrets deprovision": "the secrets manager, and with --keys the store's keys",
|
|
108
|
+
"secrets retire-version": "a field-key version, and every ciphertext still sealed under it",
|
|
108
109
|
"secrets rm": "a stored secret",
|
|
109
110
|
"storage deprovision": "the storage Worker, and with --storage its bucket",
|
|
110
111
|
"support deprovision": "the support Workers, and with --storage its attachments",
|
|
@@ -7,6 +7,8 @@ import type { BindingSpecInput } from "../capability/bindings";
|
|
|
7
7
|
import { type Capability, defineCapability, type PithyHonoEnv } from "../capability/capability";
|
|
8
8
|
import type { DatabaseSpecMap } from "../data/databases";
|
|
9
9
|
import type { KvNamespaceSpecMap, KvRegistry } from "../kv/namespaces";
|
|
10
|
+
import { readRetirement } from "../privacy/fieldRotation";
|
|
11
|
+
import { lazyPrivacyInventory } from "../privacy/inventory";
|
|
10
12
|
import { PACKAGE_NAME, PACKAGE_VERSION } from "../version.generated";
|
|
11
13
|
import { ENVIRONMENT_VAR } from "../worker/identity";
|
|
12
14
|
import { ControlPlaneConfig, type ControlPlaneConfigInput } from "./config/config";
|
|
@@ -146,6 +148,12 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
|
|
|
146
148
|
// Worker's sets and this Worker holds only its own, so `pending` and `undeclared` can differ there.
|
|
147
149
|
let migrationDatabases: readonly ComposedMigrationDatabase[] = [];
|
|
148
150
|
|
|
151
|
+
// Every capability this Worker composed, kept so the privacy inventory can walk their storage schemas
|
|
152
|
+
// (#132) — lazily, on the first control-plane read, and once for the isolate. Capturing the list costs
|
|
153
|
+
// nothing; walking it is deferred, so a Worker whose control plane is never called never walks.
|
|
154
|
+
let composedCapabilities: readonly Capability[] = [];
|
|
155
|
+
const privacy = lazyPrivacyInventory(() => composedCapabilities);
|
|
156
|
+
|
|
149
157
|
const capability = defineCapability({
|
|
150
158
|
name: "controlplane",
|
|
151
159
|
// `@pithy-sh/core`'s version, stamped by `scripts/stampVersions.ts` — a Worker cannot read its own
|
|
@@ -209,6 +217,7 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
|
|
|
209
217
|
}));
|
|
210
218
|
health = capabilityHealthSources(capabilities);
|
|
211
219
|
migrationDatabases = composeMigrationDatabases(capabilities);
|
|
220
|
+
composedCapabilities = [...capabilities];
|
|
212
221
|
},
|
|
213
222
|
middleware: [
|
|
214
223
|
(app) => {
|
|
@@ -252,6 +261,8 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
|
|
|
252
261
|
composedCapabilities: () => composed,
|
|
253
262
|
composedHealth: () => health,
|
|
254
263
|
composedMigrations: () => migrationDatabases,
|
|
264
|
+
privacyInventory: privacy,
|
|
265
|
+
retirement: (env) => readRetirement(env, composedCapabilities),
|
|
255
266
|
now,
|
|
256
267
|
});
|
|
257
268
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { PrivacyPosture } from "../../privacy/posture";
|
|
5
6
|
import { ControlPlaneScope } from "../scope/scope";
|
|
6
7
|
import { ManifestConfigKey, ManifestConfigValues } from "./configuration";
|
|
7
8
|
import { CapabilityHealthReport, HealthSummary, HealthSummaryKey, healthReport, healthWire } from "./healthSummary";
|
|
@@ -246,6 +247,16 @@ export const ControlPlaneManifest = z
|
|
|
246
247
|
.describe(
|
|
247
248
|
"The Cloudflare version id of the build answering this call, from the `CF_VERSION_METADATA` binding, or null where the binding is absent. Opaque and per-deploy: it identifies which build is running, never which features it has. Null is honest — a Worker scaffolded before the binding was declared genuinely cannot say.",
|
|
248
249
|
),
|
|
250
|
+
versionCreatedAt: z.iso
|
|
251
|
+
.datetime()
|
|
252
|
+
.nullable()
|
|
253
|
+
.optional()
|
|
254
|
+
.describe(
|
|
255
|
+
"When the version answering this call was uploaded, ISO-8601, from the same `CF_VERSION_METADATA` binding as `version` — or null where the binding is absent. The id alone cannot tell a deploy from a rollback: a rollback reactivates an existing version, so the id returns to one already seen while this moves backwards. Relayed, never interpreted. Absent from a Worker older than #132.",
|
|
256
|
+
),
|
|
257
|
+
privacy: PrivacyPosture.optional().describe(
|
|
258
|
+
"What this project declares about privacy and what its running code holds: classes and counts, never field names (`GET /control-plane/privacy` has those, behind `privacy:read`). Present on every Worker from #132, `declared` explicitly empty when nothing was declared; absent from an older Worker.",
|
|
259
|
+
),
|
|
249
260
|
capabilities: z
|
|
250
261
|
.array(CapabilityDescriptor)
|
|
251
262
|
.describe(
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { commandName } from "../../cli/command";
|
|
5
5
|
import { PithyError } from "../../error/pithyError";
|
|
6
6
|
import type { MessageParams } from "../../i18n/catalog";
|
|
7
|
+
import type { UnmarkedString } from "../../privacy/types";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The `control-plane` strategy's throw sugar. The `controlplane/*` codes live in core's closed
|
|
@@ -20,9 +21,9 @@ import type { MessageParams } from "../../i18n/catalog";
|
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
interface ControlPlaneErrorArgs {
|
|
23
|
-
message?:
|
|
24
|
-
action?:
|
|
25
|
-
detail?:
|
|
24
|
+
message?: UnmarkedString;
|
|
25
|
+
action?: UnmarkedString;
|
|
26
|
+
detail?: UnmarkedString;
|
|
26
27
|
/**
|
|
27
28
|
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
28
29
|
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
@@ -186,7 +186,21 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
|
|
|
186
186
|
},
|
|
187
187
|
c.var.log,
|
|
188
188
|
);
|
|
189
|
-
|
|
189
|
+
// A handler can refuse a call the gate's requirement let through — an operator roster write that
|
|
190
|
+
// would move the owning role is refused `controlplane/insufficient_scope`, naming the ownership route,
|
|
191
|
+
// even on a connection granted `organization:ownership:transfer`, because a token exercises the one
|
|
192
|
+
// scope it was minted for — and it refuses it itself, past this gate. That refusal is still a blocked attempt, and the trail must
|
|
193
|
+
// say so: `call_allowed` alone would hide a credential probing for the grant it lacks.
|
|
194
|
+
//
|
|
195
|
+
// Both paths, as the organization routes' refusal recorder reads them: a throw that reaches here, and
|
|
196
|
+
// `c.error`, which is where Hono's `compose` leaves a downstream throw it rendered through `onError`.
|
|
197
|
+
try {
|
|
198
|
+
await next();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await auditHandlerDenial(c, requirement, context, error);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
await auditHandlerDenial(c, requirement, context, c.error);
|
|
190
204
|
};
|
|
191
205
|
(guard as unknown as Record<symbol, unknown>)[CONTROL_PLANE_GUARD] = true;
|
|
192
206
|
return guard;
|
|
@@ -245,6 +259,51 @@ async function auditDenial(
|
|
|
245
259
|
);
|
|
246
260
|
}
|
|
247
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Record a scope refusal a handler raised after the gate let the call in.
|
|
264
|
+
*
|
|
265
|
+
* Only `controlplane/insufficient_scope`: every other refusal past the gate is the handler's own to
|
|
266
|
+
* record, and a 404 or a validation error is not a credential reaching beyond its grant. The same row as
|
|
267
|
+
* {@link auditDenial}, with two differences that follow from the call having been verified: the actor is
|
|
268
|
+
* the proven subject and its connection, and `refusedBy` says the gate passed it — `required` is the
|
|
269
|
+
* gate's requirement, which this call met; what it lacked is in the log, with the rest of `detail`.
|
|
270
|
+
*/
|
|
271
|
+
async function auditHandlerDenial(
|
|
272
|
+
c: Parameters<MiddlewareHandler<PithyHonoEnv>>[0],
|
|
273
|
+
requirement: ControlPlaneRequirement,
|
|
274
|
+
context: ControlPlaneContext,
|
|
275
|
+
cause: unknown,
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
// Read by its code, not `instanceof`: a capability that throws it may hold its own copy of this module,
|
|
278
|
+
// and a refusal that slipped past a class check would be exactly the unaudited attempt this exists for.
|
|
279
|
+
const code = (cause as { payload?: { code?: unknown } } | null)?.payload?.code;
|
|
280
|
+
if (code !== "controlplane/insufficient_scope") return;
|
|
281
|
+
await safeEmit(
|
|
282
|
+
c.var.emit,
|
|
283
|
+
{
|
|
284
|
+
action: ControlPlaneAuditActions.callDenied,
|
|
285
|
+
outcome: "denied",
|
|
286
|
+
severity: "warning",
|
|
287
|
+
actorType: "control-plane",
|
|
288
|
+
actorId: context.subject,
|
|
289
|
+
resourceType: "controlplane_connection",
|
|
290
|
+
resourceId: context.connectionId,
|
|
291
|
+
requestId: c.req.header("cf-ray"),
|
|
292
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
293
|
+
userAgent: c.req.header("user-agent"),
|
|
294
|
+
metadata: {
|
|
295
|
+
reason: code,
|
|
296
|
+
required: String(requirement),
|
|
297
|
+
refusedBy: "handler",
|
|
298
|
+
connectionId: context.connectionId,
|
|
299
|
+
method: c.req.method,
|
|
300
|
+
path: c.req.path,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
c.var.log,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
248
307
|
/**
|
|
249
308
|
* The step that connects a management client, as this deployed Worker can name it: with its own environment when
|
|
250
309
|
* it knows it, and never with a Worker — it cannot know the `apps/` directory that provisioned it (#711).
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
import type { Context } from "hono";
|
|
5
5
|
import type { PithyHonoEnv } from "../../capability/capability";
|
|
6
6
|
import { causeMessage } from "../../error/cause";
|
|
7
|
-
import {
|
|
7
|
+
import { privacyAttestation, privacyPosture } from "../../privacy/posture";
|
|
8
|
+
import type { PrivacyInventory } from "../../privacy/report";
|
|
9
|
+
import type { RetirementVerdict } from "../../privacy/retirement";
|
|
10
|
+
import { workerVersionMetadata } from "../../worker/identity";
|
|
8
11
|
import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
|
|
9
12
|
import type { ControlPlaneConfig } from "../config/config";
|
|
10
13
|
import type { ControlPlaneContext } from "../context";
|
|
@@ -20,6 +23,7 @@ import type {
|
|
|
20
23
|
ControlPlaneKeysResponse,
|
|
21
24
|
ControlPlaneMigrationsResponse,
|
|
22
25
|
ControlPlanePingResponse,
|
|
26
|
+
ControlPlanePrivacyResponse,
|
|
23
27
|
ExpireKeyResponse,
|
|
24
28
|
PublicKeyView,
|
|
25
29
|
RegisterKeyResponse,
|
|
@@ -76,6 +80,16 @@ export interface ControlPlaneHandlerDeps {
|
|
|
76
80
|
* `compose` hook through `collectMigrationSets`, the function `pithy migrate` composes with (#729).
|
|
77
81
|
*/
|
|
78
82
|
composedMigrations: () => readonly ComposedMigrationDatabase[];
|
|
83
|
+
/**
|
|
84
|
+
* The privacy inventory of every storage schema this Worker composed (#132), walked on the first call and
|
|
85
|
+
* cached for the isolate. A thunk, so a Worker whose control plane is never called never walks.
|
|
86
|
+
*/
|
|
87
|
+
privacyInventory: () => PrivacyInventory;
|
|
88
|
+
/**
|
|
89
|
+
* The field key's retirement verdict (#390): the guard over this Worker's encrypted columns, read through its own
|
|
90
|
+
* bindings. Never throws: whatever it cannot read is incomplete coverage.
|
|
91
|
+
*/
|
|
92
|
+
retirement: (env: Record<string, unknown>) => Promise<RetirementVerdict>;
|
|
79
93
|
/** The clock, injected so a test can stand at any instant. */
|
|
80
94
|
now: () => Date;
|
|
81
95
|
}
|
|
@@ -199,12 +213,23 @@ export function manifestHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
199
213
|
),
|
|
200
214
|
})),
|
|
201
215
|
);
|
|
216
|
+
// The build answering this call. Read per request rather than captured at assembly: it is the same
|
|
217
|
+
// binding every log record and audit event reads, and there is one reader for it.
|
|
218
|
+
const build = workerVersionMetadata(c.env);
|
|
202
219
|
return c.json({
|
|
203
220
|
environment: context.environment,
|
|
204
221
|
connectionId: context.connectionId,
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
version
|
|
222
|
+
version: build.id,
|
|
223
|
+
// Beside the id, because the id alone cannot tell a deploy from a rollback (#132): a rollback
|
|
224
|
+
// reactivates a version already seen, and this is the one field that moves backwards when it does.
|
|
225
|
+
versionCreatedAt: build.createdAt,
|
|
226
|
+
// Classes and counts, never field names — those are `GET /privacy`'s, behind their own scope. A project
|
|
227
|
+
// that declared nothing walks nothing; otherwise the walk is the isolate's cached one.
|
|
228
|
+
// The retirement verdict beside it (#390), so a dashboard reads the guard rather than inferring it.
|
|
229
|
+
privacy: {
|
|
230
|
+
...privacyPosture(privacyAttestation(c.env), deps.privacyInventory),
|
|
231
|
+
retirement: await deps.retirement(c.env as Record<string, unknown>),
|
|
232
|
+
},
|
|
208
233
|
capabilities,
|
|
209
234
|
grantedScopes: [...context.grantedScopes],
|
|
210
235
|
} satisfies ControlPlaneManifestWire);
|
|
@@ -385,3 +410,31 @@ export function expireKeyHandler(deps: ControlPlaneHandlerDeps) {
|
|
|
385
410
|
} satisfies ExpireKeyResponse);
|
|
386
411
|
};
|
|
387
412
|
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `GET /control-plane/privacy` — the full privacy inventory (#132).
|
|
416
|
+
*
|
|
417
|
+
* Every storage schema this Worker composed that holds a marked field, each field's path and class, and where it
|
|
418
|
+
* is stored — derived from the running code, so unmarking a field removes it on the next deploy with nothing in
|
|
419
|
+
* between. Behind `privacy:read`, because field names are the kind of thing an adopter may withhold from a client
|
|
420
|
+
* that has no business seeing them. Read-only.
|
|
421
|
+
*/
|
|
422
|
+
export function privacyHandler(deps: ControlPlaneHandlerDeps) {
|
|
423
|
+
return async (c: Context<PithyHonoEnv>) => {
|
|
424
|
+
const context = caller(c);
|
|
425
|
+
const inventory = deps.privacyInventory();
|
|
426
|
+
return c.json({
|
|
427
|
+
environment: context.environment,
|
|
428
|
+
posture: {
|
|
429
|
+
...privacyPosture(privacyAttestation(c.env), () => inventory),
|
|
430
|
+
retirement: await deps.retirement(c.env as Record<string, unknown>),
|
|
431
|
+
},
|
|
432
|
+
schemasWalked: inventory.schemasWalked,
|
|
433
|
+
schemas: inventory.schemas.map((schema) => ({
|
|
434
|
+
...schema,
|
|
435
|
+
fields: schema.fields.map((field) => ({ ...field })),
|
|
436
|
+
encrypted: schema.encrypted.map((field) => ({ ...field })),
|
|
437
|
+
})),
|
|
438
|
+
} satisfies ControlPlanePrivacyResponse);
|
|
439
|
+
};
|
|
440
|
+
}
|