@pithy-sh/core 0.10.0 → 0.11.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/access/access.d.ts +172 -0
- package/dist/access/access.d.ts.map +1 -0
- package/dist/access/access.js +147 -0
- package/dist/access/acting.d.ts +49 -0
- package/dist/access/acting.d.ts.map +1 -0
- package/dist/access/acting.js +21 -0
- package/dist/access/policyCheck.d.ts +33 -0
- package/dist/access/policyCheck.d.ts.map +1 -0
- package/dist/access/policyCheck.js +48 -0
- package/dist/access/usage.d.ts +83 -0
- package/dist/access/usage.d.ts.map +1 -0
- package/dist/access/usage.js +104 -0
- package/dist/access/visibility.d.ts +66 -0
- package/dist/access/visibility.d.ts.map +1 -0
- package/dist/access/visibility.js +86 -0
- package/dist/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/capability/bindings.d.ts +47 -2
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +117 -7
- package/dist/capability/capability.d.ts +98 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/manifest.d.ts +2 -1
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/settings.d.ts +86 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +80 -1
- package/dist/cli/commandCatalog.generated.d.ts +184 -22
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +196 -22
- package/dist/cli/commandFacts.d.ts +1 -1
- 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 +12 -2
- package/dist/controlPlane/context.d.ts +5 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -1
- package/dist/controlPlane/data/connection.d.ts +47 -0
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +55 -1
- package/dist/controlPlane/http/cors.d.ts +4 -4
- package/dist/controlPlane/http/cors.js +4 -4
- package/dist/controlPlane/http/guard.d.ts +11 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +17 -1
- package/dist/controlPlane/http/handlers.d.ts +60 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +31 -2
- package/dist/controlPlane/http/readiness.d.ts +33 -0
- package/dist/controlPlane/http/readiness.d.ts.map +1 -0
- package/dist/controlPlane/http/readiness.js +39 -0
- package/dist/controlPlane/http/responses.d.ts +147 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +61 -4
- 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/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +2 -1
- package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
- package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
- package/dist/controlPlane/migrationState/migrationState.js +142 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
- package/dist/controlPlane/scope/scope.d.ts +13 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +7 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/entitlement/allowance.d.ts +25 -0
- package/dist/entitlement/allowance.d.ts.map +1 -0
- package/dist/entitlement/allowance.js +80 -0
- package/dist/entitlement/entitlement.d.ts +41 -0
- package/dist/entitlement/entitlement.d.ts.map +1 -1
- package/dist/entitlement/meter.d.ts +57 -0
- package/dist/entitlement/meter.d.ts.map +1 -0
- package/dist/entitlement/meter.js +13 -0
- package/dist/error/http.d.ts +144 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +288 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +100 -4
- package/dist/http/authContext.d.ts +7 -0
- package/dist/http/authContext.d.ts.map +1 -1
- package/dist/http/authContext.js +8 -1
- package/dist/http/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/record.d.ts +84 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/capabilitySets.d.ts +23 -0
- package/dist/migrations/capabilitySets.d.ts.map +1 -0
- package/dist/migrations/capabilitySets.js +74 -0
- package/dist/migrations/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- package/dist/migrations/state.d.ts +60 -0
- package/dist/migrations/state.d.ts.map +1 -0
- package/dist/migrations/state.js +46 -0
- package/dist/naming/feature.d.ts +27 -5
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +36 -6
- package/dist/naming/limits.d.ts +47 -3
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +47 -3
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +8 -3
- package/dist/naming/resourceNames.d.ts +21 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +21 -3
- package/dist/seed/writeD1.d.ts +7 -1
- package/dist/seed/writeD1.d.ts.map +1 -1
- package/dist/seed/writeD1.js +9 -3
- package/dist/seed/writeKv.d.ts +2 -0
- package/dist/seed/writeKv.d.ts.map +1 -1
- package/dist/seed/writeKv.js +6 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/serviceLoopback.d.ts +36 -0
- package/dist/worker/serviceLoopback.d.ts.map +1 -0
- package/dist/worker/serviceLoopback.js +74 -0
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +22 -15
- package/package.json +2 -2
- package/src/access/access.ts +298 -0
- package/src/access/acting.ts +59 -0
- package/src/access/policyCheck.ts +77 -0
- package/src/access/usage.ts +141 -0
- package/src/access/visibility.ts +109 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +99 -29
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +212 -22
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +25 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +41 -0
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +140 -2
- package/src/controlPlane/http/routes.ts +25 -1
- package/src/controlPlane/http/verify.ts +2 -0
- package/src/controlPlane/migrationState/migrationState.ts +200 -0
- package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
- package/src/controlPlane/scope/scope.ts +14 -2
- package/src/createBackend.ts +24 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/payload.ts +211 -4
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +1 -0
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/state.ts +94 -0
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/provisionScope.ts +19 -1
- package/src/naming/resourceNames.ts +52 -5
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/faults.ts +51 -24
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { MigrationProvider } from "kysely/migration";
|
|
6
|
+
import type { Capability } from "../../capability/capability";
|
|
7
|
+
import { composeDatabases } from "../../data/databases";
|
|
8
|
+
import { InternalError } from "../../error/pithyError";
|
|
9
|
+
import { collectMigrationSets } from "../../migrations/capabilitySets";
|
|
10
|
+
import { composedMigrationKey, createMigrationRegistry, decomposeMigrationKey } from "../../migrations/registry";
|
|
11
|
+
import { readMigrationLedger } from "../../migrations/runner";
|
|
12
|
+
import { type MigrationLastGroup, readMigrationState } from "../../migrations/state";
|
|
13
|
+
import type { CapabilityHealthInput } from "../discovery/health";
|
|
14
|
+
import type { HealthSummary } from "../discovery/healthSummary";
|
|
15
|
+
import type { MigrationDatabaseView, MigrationGroupView, MigrationView } from "../http/responses";
|
|
16
|
+
import { MIGRATIONS_READ_SCOPE } from "../scope/scope";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* **Migration state over the control plane (#729).**
|
|
20
|
+
*
|
|
21
|
+
* A management client could not answer what is applied where, or whether a deploy left something behind:
|
|
22
|
+
* the seam registered a ping, the manifest and key rotation, and not one field of it was about a database.
|
|
23
|
+
* The read already existed — {@link readMigrationLedger} answers both directions — and it needs a D1
|
|
24
|
+
* binding and the composed provider in one process. **The Worker is the only thing holding both**, and it
|
|
25
|
+
* is the deployed build, so asked from its side the answer cannot be stale the way the project on disk is.
|
|
26
|
+
*
|
|
27
|
+
* Two halves. At assembly, {@link composeMigrationDatabases} builds what this Worker declares per bound
|
|
28
|
+
* database, through `collectMigrationSets` — the function `pithy migrate` composes with, so the route and
|
|
29
|
+
* `pithy doctor` compose the same way. Not always from the same inputs: on a D1 two Workers share, the CLI
|
|
30
|
+
* merges both Workers' sets and the route holds only its own, so `pending` and `undeclared` can differ
|
|
31
|
+
* there. On a database one Worker binds alone they agree. Per request, {@link readDatabaseMigrations} reads
|
|
32
|
+
* one database for the route and {@link readMigrationHealth} totals all of them for the manifest.
|
|
33
|
+
*
|
|
34
|
+
* **Read-only.** Nothing here runs, reverses or resets a migration. A screen somebody opened to ask must
|
|
35
|
+
* not hand them the thing that changes the schema.
|
|
36
|
+
*
|
|
37
|
+
* **`undeclared` is relative to this Worker.** Two Workers can be bound to one physical D1, and the CLI
|
|
38
|
+
* composes across all of them. A Worker composes only its own capabilities and cannot learn a co-bound
|
|
39
|
+
* Worker's, so from inside one, every migration the other shipped to the shared database reads as
|
|
40
|
+
* undeclared. That is why `undeclaredMigrations` grades nothing.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** One database this Worker binds, with what this build declares for it. Built once, at assembly. */
|
|
44
|
+
export interface ComposedMigrationDatabase {
|
|
45
|
+
/** The database name, as capabilities declare it in `databases`. */
|
|
46
|
+
database: string;
|
|
47
|
+
/** The D1 binding it is read through. */
|
|
48
|
+
binding: string;
|
|
49
|
+
/** Every migration this build declares for it — empty for a database bound with none. */
|
|
50
|
+
provider: MigrationProvider;
|
|
51
|
+
/** Each declared migration's namespace, known from the set it came from. */
|
|
52
|
+
namespaces: ReadonlyMap<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A provider declaring nothing, for a database bound with no migrations: everything applied is undeclared. */
|
|
56
|
+
const NOTHING_DECLARED: MigrationProvider = { getMigrations: async () => ({}) };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every database this Worker binds, with the provider this build composes for it, sorted by name.
|
|
60
|
+
*
|
|
61
|
+
* One entry per database `composeDatabases` maps to a binding — the same map the request registry is built
|
|
62
|
+
* from — so a database bound with no migrations is still reported. Leaving it out would read as not bound.
|
|
63
|
+
*/
|
|
64
|
+
export function composeMigrationDatabases(capabilities: readonly Capability[]): ComposedMigrationDatabase[] {
|
|
65
|
+
const sets = collectMigrationSets([...capabilities]);
|
|
66
|
+
const registry = createMigrationRegistry(sets);
|
|
67
|
+
const bound = composeDatabases([...capabilities]);
|
|
68
|
+
return Object.entries(bound)
|
|
69
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
70
|
+
.map(([database, group]) => {
|
|
71
|
+
const namespaces = new Map<string, string>();
|
|
72
|
+
for (const set of sets) {
|
|
73
|
+
if (set.database !== database) continue;
|
|
74
|
+
for (const localKey of Object.keys(set.migrations))
|
|
75
|
+
namespaces.set(composedMigrationKey(set, localKey), set.namespace);
|
|
76
|
+
}
|
|
77
|
+
return { database, binding: group.binding, provider: registry[database] ?? NOTHING_DECLARED, namespaces };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One migration as the route reports it.
|
|
83
|
+
*
|
|
84
|
+
* A declared migration's namespace comes from the set it was declared in. An undeclared one has only its
|
|
85
|
+
* key, so it is decomposed — and a key no registry composed answers null rather than a guess.
|
|
86
|
+
*/
|
|
87
|
+
export function migrationView(name: string, namespaces: ReadonlyMap<string, string>): MigrationView {
|
|
88
|
+
return { name, namespace: namespaces.get(name) ?? decomposeMigrationKey(name)?.namespace ?? null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The D1 binding for `entry`, or a refusal naming it — a Worker missing the binding cannot read it. */
|
|
92
|
+
function bindingOf(env: Record<string, unknown>, entry: ComposedMigrationDatabase): D1Database {
|
|
93
|
+
const database = env[entry.binding];
|
|
94
|
+
if (database == null) {
|
|
95
|
+
throw new InternalError({
|
|
96
|
+
message: `The ${entry.binding} binding is missing, so database "${entry.database}" cannot be read.`,
|
|
97
|
+
action: `Declare ${entry.binding} in this Worker's wrangler.jsonc, then deploy again.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return database as D1Database;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function groupView(group: MigrationLastGroup, namespaces: ReadonlyMap<string, string>): MigrationGroupView {
|
|
104
|
+
return {
|
|
105
|
+
group: group.group,
|
|
106
|
+
appliedAt: group.appliedAt.toISOString(),
|
|
107
|
+
migrations: group.migrations.map((name) => migrationView(name, namespaces)),
|
|
108
|
+
position:
|
|
109
|
+
group.position.state === "top"
|
|
110
|
+
? { state: "top" }
|
|
111
|
+
: {
|
|
112
|
+
state: "buried",
|
|
113
|
+
above: group.position.above,
|
|
114
|
+
ungrouped: group.position.ungrouped.map((name) => migrationView(name, namespaces)),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read one database's migration state for the route.
|
|
121
|
+
*
|
|
122
|
+
* **A database that cannot be read is `unreadable`, and costs only itself.** The cause goes to
|
|
123
|
+
* `onUnreadable` — the route logs it, where the operator's context stays the operator's — and never into
|
|
124
|
+
* the view: a D1 error can name a table or a query, and the view crosses to a management client.
|
|
125
|
+
*/
|
|
126
|
+
export async function readDatabaseMigrations(
|
|
127
|
+
env: Record<string, unknown>,
|
|
128
|
+
entry: ComposedMigrationDatabase,
|
|
129
|
+
onUnreadable?: (entry: ComposedMigrationDatabase, cause: unknown) => void,
|
|
130
|
+
): Promise<MigrationDatabaseView> {
|
|
131
|
+
try {
|
|
132
|
+
const state = await readMigrationState(bindingOf(env, entry), entry.provider);
|
|
133
|
+
const view = (name: string) => migrationView(name, entry.namespaces);
|
|
134
|
+
return {
|
|
135
|
+
state: "readable",
|
|
136
|
+
database: entry.database,
|
|
137
|
+
binding: entry.binding,
|
|
138
|
+
applied: state.applied.map(view),
|
|
139
|
+
pending: state.pending.map(view),
|
|
140
|
+
undeclared: state.undeclared.map(view),
|
|
141
|
+
lastGroup: state.lastGroup ? groupView(state.lastGroup, entry.namespaces) : null,
|
|
142
|
+
};
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
onUnreadable?.(entry, cause);
|
|
145
|
+
return { state: "unreadable", database: entry.database, binding: entry.binding };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The two health keys the seam contributes to its own manifest entry.
|
|
151
|
+
*
|
|
152
|
+
* Both `indexed`: one small select per bound database, bounded by the declared migration count rather
|
|
153
|
+
* than by the adopter's data. Both behind {@link MIGRATIONS_READ_SCOPE}, which the seam's own route
|
|
154
|
+
* requires, so a connection without it gets no value rather than a zero.
|
|
155
|
+
*
|
|
156
|
+
* **`undeclaredMigrations` declares no nominal, on purpose.** From inside one Worker, a database shared
|
|
157
|
+
* with another lists the other's migrations as undeclared, so `atMost: 0` would report a corrupted chain
|
|
158
|
+
* on a database that is entirely fine. Null is what a client reads as unknowable, never as healthy (#471).
|
|
159
|
+
*/
|
|
160
|
+
export const MIGRATION_HEALTH_KEYS = [
|
|
161
|
+
{
|
|
162
|
+
key: "pendingMigrations",
|
|
163
|
+
kind: "count",
|
|
164
|
+
states: null,
|
|
165
|
+
scope: MIGRATIONS_READ_SCOPE,
|
|
166
|
+
cost: "indexed",
|
|
167
|
+
summary: "Migrations this build declares that a bound database has not applied.",
|
|
168
|
+
nominal: { atMost: 0 },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: "undeclaredMigrations",
|
|
172
|
+
kind: "count",
|
|
173
|
+
states: null,
|
|
174
|
+
scope: MIGRATIONS_READ_SCOPE,
|
|
175
|
+
cost: "indexed",
|
|
176
|
+
summary:
|
|
177
|
+
"Migrations a bound database holds that this Worker does not declare. Relative to this Worker: a database shared with another lists that Worker's migrations here.",
|
|
178
|
+
nominal: null,
|
|
179
|
+
},
|
|
180
|
+
] as const satisfies CapabilityHealthInput["keys"];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Total pending and undeclared across every bound database, for the manifest.
|
|
184
|
+
*
|
|
185
|
+
* **Throws rather than total a database it could not read.** A partial total is a zero for the database
|
|
186
|
+
* nobody read. The seam catches the throw and reports the summary `unavailable`, which a client reads as
|
|
187
|
+
* unknown — null is never zero.
|
|
188
|
+
*/
|
|
189
|
+
export async function readMigrationHealth(
|
|
190
|
+
env: Record<string, unknown>,
|
|
191
|
+
databases: readonly ComposedMigrationDatabase[],
|
|
192
|
+
): Promise<HealthSummary> {
|
|
193
|
+
const ledgers = await Promise.all(
|
|
194
|
+
databases.map((entry) => readMigrationLedger(bindingOf(env, entry), entry.provider)),
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
pendingMigrations: ledgers.reduce((total, ledger) => total + ledger.pending.length, 0),
|
|
198
|
+
undeclaredMigrations: ledgers.reduce((total, ledger) => total + ledger.undeclared.length, 0),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Who sanctioned a connection, what kind of client it is, and what it is for — `#721`.
|
|
9
|
+
*
|
|
10
|
+
* **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by
|
|
11
|
+
* it, so the three columns arrive in a migration of their own, as `0002`'s origin did.
|
|
12
|
+
*
|
|
13
|
+
* **Nullable, with no default, and nothing backfilled.** A row registered before this carries no
|
|
14
|
+
* principal, and none can be recovered: most of the trail that might be mined for one names a token
|
|
15
|
+
* rather than a person, and a principal read off a token name is the self-assertion this issue exists to
|
|
16
|
+
* end. So null means *registered before principals were recorded*, the connection keeps working exactly
|
|
17
|
+
* as it did, its purpose may be set with `pithy dashboard connect --update --purpose`, and its principal
|
|
18
|
+
* is set only by registering again, under a signed-in session.
|
|
19
|
+
*
|
|
20
|
+
* **The Worker reads only `actorKind`**, to stamp it onto the audit rows a connection's calls write. The
|
|
21
|
+
* other two exist for the adopter's own review, on their own side.
|
|
22
|
+
*/
|
|
23
|
+
export const controlplane_0003_connection_principal: Migration = {
|
|
24
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
25
|
+
await db.schema.alterTable("pithyControlplaneConnections").addColumn("authorizedBy", "text").execute();
|
|
26
|
+
await db.schema.alterTable("pithyControlplaneConnections").addColumn("actorKind", "text").execute();
|
|
27
|
+
await db.schema.alterTable("pithyControlplaneConnections").addColumn("purpose", "text").execute();
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
31
|
+
// Dropped column by column, newest first, for the reason `0002` gives: D1's engine drops a column in
|
|
32
|
+
// place, and a twelve-step rebuild would rewrite every row of a table holding live credentials.
|
|
33
|
+
await db.schema.alterTable("pithyControlplaneConnections").dropColumn("purpose").execute();
|
|
34
|
+
await db.schema.alterTable("pithyControlplaneConnections").dropColumn("actorKind").execute();
|
|
35
|
+
await db.schema.alterTable("pithyControlplaneConnections").dropColumn("authorizedBy").execute();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -78,8 +78,20 @@ export const MANIFEST_READ_SCOPE: ControlPlaneScope = "manifest:read";
|
|
|
78
78
|
export const KEYS_ROTATE_SCOPE: ControlPlaneScope = "keys:rotate";
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
81
|
+
* Read where each database this Worker binds stands: what is applied, what is pending, what is undeclared,
|
|
82
|
+
* and which run put it there (#729). Read-only by construction — every route behind it is a `GET`, and
|
|
83
|
+
* nothing over the seam runs, reverses or resets a migration. The two migration health keys on the seam's
|
|
84
|
+
* manifest entry sit behind it too, so a connection without it gets no number rather than a zero.
|
|
85
|
+
*/
|
|
86
|
+
export const MIGRATIONS_READ_SCOPE: ControlPlaneScope = "migrations:read";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
|
|
90
|
+
* because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
|
|
91
|
+
*
|
|
92
|
+
* **{@link MIGRATIONS_READ_SCOPE} is absent on purpose.** It is a read — every route requiring it is a `GET`
|
|
93
|
+
* — so `connect` derives it from the seam's `adminRoutes` the way it derives every capability's reads. The
|
|
94
|
+
* base exists for `keys:rotate`, which is not a read and would otherwise drop out.
|
|
83
95
|
*/
|
|
84
96
|
export const SEAM_SCOPES: readonly ControlPlaneScope[] = [MANIFEST_READ_SCOPE, KEYS_ROTATE_SCOPE];
|
|
85
97
|
|
package/src/createBackend.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { Hono } from "hono";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { noActingOrganization } from "./access/acting";
|
|
6
7
|
import { noopEmit } from "./audit/recorder";
|
|
7
8
|
import { BindingSpec } from "./capability/bindings";
|
|
8
9
|
import type {
|
|
@@ -18,6 +19,7 @@ import { validateBindings } from "./capability/validateBindings";
|
|
|
18
19
|
import { pithyCommandOf } from "./cli/command";
|
|
19
20
|
import { isControlPlaneCapability } from "./controlPlane/capability";
|
|
20
21
|
import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors";
|
|
22
|
+
import { registerReadinessGuards } from "./controlPlane/http/readiness";
|
|
21
23
|
import { buildDbRegistry, composeDatabases, type DbRegistry } from "./data/databases";
|
|
22
24
|
import { noEntitlementProvider } from "./entitlement/entitlement";
|
|
23
25
|
import { ValidationError } from "./error/pithyError";
|
|
@@ -205,15 +207,19 @@ export function createBackend<
|
|
|
205
207
|
// not here). Defaults to the CF-native Mode 2 logger — structured records to Workers Logs, zero-config.
|
|
206
208
|
const baseLogger = options.logger ?? createWorkerLogger();
|
|
207
209
|
|
|
210
|
+
// Every composed capability's `adminRoutes`, folded per path: the surface CORS answers for, and the
|
|
211
|
+
// surface no readiness guard may refuse. One computation, so the two cannot disagree.
|
|
212
|
+
const adminSurface = corsSurface(all);
|
|
213
|
+
|
|
208
214
|
// **CORS first — before this Worker's own base middleware and before every capability's.**
|
|
209
215
|
//
|
|
210
216
|
// Hono runs middleware in registration order, so whatever registers first is the only thing that can
|
|
211
217
|
// put a header on a response somebody else short-circuits. Capabilities short-circuit for real
|
|
212
|
-
// reasons: `@pithy-sh/vector`'s `provisionGuard
|
|
213
|
-
// when the provision record disagrees with config.
|
|
214
|
-
//
|
|
215
|
-
// opaque `fetch` TypeError this exists to remove
|
|
216
|
-
//
|
|
218
|
+
// reasons: a rate limit, or a readiness guard like `@pithy-sh/vector`'s `provisionGuard`, which
|
|
219
|
+
// throws on every request off the admin surface when the provision record disagrees with config.
|
|
220
|
+
// Register CORS after either and the refusal reaches a browser with no `Access-Control-Allow-Origin`,
|
|
221
|
+
// so the dashboard sees the opaque `fetch` TypeError this exists to remove instead of the reason.
|
|
222
|
+
// Ahead of `validateBindings` for the same reason.
|
|
217
223
|
//
|
|
218
224
|
// It lives here rather than in the capability's `middleware` hook because that hook runs inside the
|
|
219
225
|
// loop below, in the adopter's composition order, and this must not depend on where they listed
|
|
@@ -222,7 +228,7 @@ export function createBackend<
|
|
|
222
228
|
const controlPlane = all.find(isControlPlaneCapability);
|
|
223
229
|
if (controlPlane) {
|
|
224
230
|
const cors = controlPlane.controlPlaneConfig;
|
|
225
|
-
registerControlPlaneCors(app,
|
|
231
|
+
registerControlPlaneCors(app, adminSurface, allowedOriginSet(cors), cors.corsMaxAgeSeconds);
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
let validated = false;
|
|
@@ -233,6 +239,9 @@ export function createBackend<
|
|
|
233
239
|
validated = true;
|
|
234
240
|
}
|
|
235
241
|
if (c.get("auth") === undefined) c.set("auth", null);
|
|
242
|
+
// Nothing in force until `@pithy-sh/organization`'s middleware publishes a resolver. Media and storage
|
|
243
|
+
// read it to scope every record, so a Worker with no tenancy answers null rather than throwing.
|
|
244
|
+
if (c.get("actingOrganization") === undefined) c.set("actingOrganization", noActingOrganization);
|
|
236
245
|
// Null means nothing negotiated, which is not the same fact as "the default was chosen" — only
|
|
237
246
|
// `@pithy-sh/i18n` ever sets it, and `c.var.t` works either way.
|
|
238
247
|
if (c.get("locale") === undefined) c.set("locale", null);
|
|
@@ -316,6 +325,15 @@ export function createBackend<
|
|
|
316
325
|
for (const cap of all) {
|
|
317
326
|
for (const middleware of cap.middleware ?? []) middleware(app);
|
|
318
327
|
}
|
|
328
|
+
// Readiness guards after every capability's middleware, so the verifier, `emit` and every rate limit
|
|
329
|
+
// are in place, and before any capability's routes, so a terminal handler cannot outrun them. Each is
|
|
330
|
+
// skipped on the admin surface: the control plane answers whatever else is wrong with the Worker,
|
|
331
|
+
// because it is how a management client finds out what that is (#763).
|
|
332
|
+
registerReadinessGuards(
|
|
333
|
+
app,
|
|
334
|
+
adminSurface,
|
|
335
|
+
all.flatMap((cap) => cap.readiness ?? []),
|
|
336
|
+
);
|
|
319
337
|
// Each hook's slice of the route table is noted as it mounts, so the OpenAPI contract can tag every
|
|
320
338
|
// operation with the capability that owns it (`openapi/routeGroups.ts`).
|
|
321
339
|
for (const cap of all) {
|
package/src/createEntrypoint.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ExecutionContext, ForwardableEmailMessage } from "@cloudflare/workers-types";
|
|
5
5
|
import type { Capability, CapabilityEmailHandler } from "./capability/capability";
|
|
6
6
|
import { type CreateBackendOptions, createBackend } from "./createBackend";
|
|
7
|
+
import { withLocalServices } from "./worker/serviceLoopback";
|
|
7
8
|
import { triggerWorkflow } from "./workflow/dispatch";
|
|
8
9
|
import { composeWorkflows, scheduledWorkflows } from "./workflow/register";
|
|
9
10
|
|
|
@@ -88,7 +89,11 @@ export function createEntrypoint<
|
|
|
88
89
|
const registry = composeWorkflows(all);
|
|
89
90
|
const scheduled = scheduledWorkflows(registry);
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// Under `pithy dev`, a service binding's local stand-in goes into the env before anything reads it — the
|
|
93
|
+
// binding check included (#617). Anywhere else this hands back the env it was given.
|
|
94
|
+
const entrypoint: PithyEntrypoint = {
|
|
95
|
+
fetch: (request, env, ctx) => app.fetch(request, withLocalServices(env), ctx),
|
|
96
|
+
};
|
|
92
97
|
|
|
93
98
|
// Cron-carrying jobs get the Worker's one `scheduled` entry. Each is started independently so one
|
|
94
99
|
// job whose binding is missing cannot stop the rest of the schedule from running; the failure is
|
|
@@ -102,7 +107,7 @@ export function createEntrypoint<
|
|
|
102
107
|
// can never receive on a schedule therefore fails loudly here, which is the author's bug and
|
|
103
108
|
// is exactly what should surface.
|
|
104
109
|
try {
|
|
105
|
-
await triggerWorkflow(env, registry, entry.key, {});
|
|
110
|
+
await triggerWorkflow(withLocalServices(env), registry, entry.key, {});
|
|
106
111
|
} catch (error) {
|
|
107
112
|
failures.push(error);
|
|
108
113
|
}
|
|
@@ -114,7 +119,8 @@ export function createEntrypoint<
|
|
|
114
119
|
// Only expose an `email` handler when something actually handles inbound mail — an inert one would
|
|
115
120
|
// silently drop any message Email Routing delivered to this Worker.
|
|
116
121
|
if (handlers.length > 0) {
|
|
117
|
-
entrypoint.email = async (message,
|
|
122
|
+
entrypoint.email = async (message, given, ctx) => {
|
|
123
|
+
const env = withLocalServices(given);
|
|
118
124
|
if (handlers.length === 1) {
|
|
119
125
|
await handlers[0]?.(message, env, ctx);
|
|
120
126
|
return;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import type { PithyHonoEnv } from "../capability/capability";
|
|
6
|
+
import { InternalError, messageOf } from "../error/pithyError";
|
|
7
|
+
import type { MeterLimit } from "./entitlement";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The allowance readers — how much the current caller may have, and whether a switch is on.
|
|
11
|
+
*
|
|
12
|
+
* const seats = await quota(c, "seats"); // number, or null for unlimited
|
|
13
|
+
* if (seats !== null && (await countMembers(orgId)) >= seats) throw new SeatLimitReached(…);
|
|
14
|
+
* if (await feature(c, "auditExport")) { … } // boolean, never null
|
|
15
|
+
*
|
|
16
|
+
* **Neither takes an entitlement key, and that is the point.** A route does not know which tier the
|
|
17
|
+
* caller's holder is on, and during an upgrade overlap it holds two. The provider resolves across every
|
|
18
|
+
* key the holder has and answers the higher, so an adopter compares numbers and never ranks tiers.
|
|
19
|
+
*
|
|
20
|
+
* **Read, never enforced.** A quota denies nothing on its own and a feature set to `false` denies
|
|
21
|
+
* nothing either — `requireEntitlement` still gates on the key alone. What a number means for a request
|
|
22
|
+
* is the adopter's code to decide, because only the adopter can count what it owns.
|
|
23
|
+
*
|
|
24
|
+
* **They live in core for the reason `requireEntitlement` does.** A reader that arrived with
|
|
25
|
+
* `@pithy-sh/payments` would be absent when payments is, and a route reading an allowance must then fail
|
|
26
|
+
* rather than read something. So with no provider composed — or one that declares no allowances — both
|
|
27
|
+
* throw `core/internal` naming the gap. Never `0`, which is a paywall shut for a reason nobody can see;
|
|
28
|
+
* never unlimited, which is a paywall that does not exist.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The refusal when nothing on the request can answer an allowance. The adopter's wiring, so `core/internal`. */
|
|
32
|
+
function noProvider(c: Context<PithyHonoEnv>, kind: "quota" | "feature", name: string): InternalError {
|
|
33
|
+
const provider = c.var.entitlements?.provider ?? null;
|
|
34
|
+
return new InternalError({
|
|
35
|
+
message: `No ${kind} "${name}" can be resolved here.`,
|
|
36
|
+
action: "Compose a payments capability that declares its entitlements in this Worker's pithy.config.ts.",
|
|
37
|
+
detail:
|
|
38
|
+
provider === null
|
|
39
|
+
? `no entitlement provider is composed, so ${kind}("${name}") has nothing to read.`
|
|
40
|
+
: `${provider} resolves entitlements and declares no allowances, so ${kind}("${name}") has nothing to read.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How many of `name` the caller's holder may have: a whole number, or `null` for unlimited. */
|
|
45
|
+
export async function quota(c: Context<PithyHonoEnv>, name: string): Promise<number | null> {
|
|
46
|
+
const resolver = c.var.entitlements;
|
|
47
|
+
if (!resolver?.quota) throw noProvider(c, "quota", name);
|
|
48
|
+
return resolver.quota(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether `name` is on for the caller's holder. Always a boolean. */
|
|
52
|
+
export async function feature(c: Context<PithyHonoEnv>, name: string): Promise<boolean> {
|
|
53
|
+
const resolver = c.var.entitlements;
|
|
54
|
+
if (!resolver?.feature) throw noProvider(c, "feature", name);
|
|
55
|
+
return resolver.feature(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The ceiling a capability enforces on a meter it counts itself, for the current caller.
|
|
60
|
+
*
|
|
61
|
+
* **No provider, or one that binds nothing to this meter, is `{ enforced: false }`** — and the capability
|
|
62
|
+
* keeps its own configured ceiling. Enforcement is opt-in per meter because a cap that arrives by surprise
|
|
63
|
+
* breaks an adopter who was already over it.
|
|
64
|
+
*
|
|
65
|
+
* **A provider that throws refuses, and logs.** Storage already says which way round to err: over-counting
|
|
66
|
+
* briefly and reclaiming is the right way round, and under-counting has no recovery. Treating a failed read
|
|
67
|
+
* as unenforced would hand a customer an unbounded allowance for as long as the read kept failing. The cost
|
|
68
|
+
* is real and is stated rather than discovered: our bug becomes a refused write for a paying customer.
|
|
69
|
+
* Enforcement being opt-in is what bounds it — an adopter who would rather over-serve leaves the meter
|
|
70
|
+
* advisory.
|
|
71
|
+
*/
|
|
72
|
+
export async function meterLimit(c: Context<PithyHonoEnv>, meter: string): Promise<MeterLimit> {
|
|
73
|
+
const resolver = c.var.entitlements;
|
|
74
|
+
if (!resolver?.meterLimit) return { enforced: false };
|
|
75
|
+
try {
|
|
76
|
+
return await resolver.meterLimit(meter);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
c.var.log?.error("meter limit resolution failed", { provider: resolver.provider, meter, cause: messageOf(error) });
|
|
79
|
+
throw new InternalError({
|
|
80
|
+
message: "Usage could not be checked, so nothing was written.",
|
|
81
|
+
action: "Read the preceding log line for why the allowance read failed.",
|
|
82
|
+
detail: `${resolver.provider} threw resolving the ${meter} meter's ceiling; refusing rather than serving unbounded.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -132,8 +132,46 @@ export interface EntitlementResolver {
|
|
|
132
132
|
* See {@link EntitlementHolder} for why this is a label and a tenant rather than the holder itself.
|
|
133
133
|
*/
|
|
134
134
|
holder?(): Promise<EntitlementHolder | undefined>;
|
|
135
|
+
/**
|
|
136
|
+
* How many of `name` the current caller's holder may have — a whole number, or `null` for unlimited.
|
|
137
|
+
* Optional: a provider with no allowances omits it, and {@link import("./allowance").quota} refuses
|
|
138
|
+
* rather than inventing a number. A provider throws for a name it does not declare, never answers 0.
|
|
139
|
+
*/
|
|
140
|
+
quota?(name: string): Promise<number | null>;
|
|
141
|
+
/** Whether `name` is on for the current caller's holder. Never null. Optional, as {@link quota} is. */
|
|
142
|
+
feature?(name: string): Promise<boolean>;
|
|
143
|
+
/**
|
|
144
|
+
* The ceiling a capability that counts its own rows enforces for `meter` — storage's bytes, media's
|
|
145
|
+
* items — or `{ enforced: false }` where the provider binds no ceiling to it. Optional, and absent is
|
|
146
|
+
* `{ enforced: false }`: a capability composed with no provider keeps its own configured behavior.
|
|
147
|
+
*/
|
|
148
|
+
meterLimit?(meter: string): Promise<MeterLimit>;
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
/**
|
|
152
|
+
* What a provider says about one meter for the current caller: whether it binds a ceiling at all, and if
|
|
153
|
+
* it does, what the ceiling is and whose rows it covers. `limit: null` is unlimited — an enforced meter on
|
|
154
|
+
* a subject whose plan or override says so.
|
|
155
|
+
*
|
|
156
|
+
* Two states rather than a nullable number, because "this meter is advisory here" and "this subject is
|
|
157
|
+
* unlimited" are different facts and a capability must fall back to its own configured ceiling on the
|
|
158
|
+
* first and never on the second.
|
|
159
|
+
*
|
|
160
|
+
* **An enforced ceiling says which rows it is the whole of**, because a ceiling counted against the wrong
|
|
161
|
+
* rows is a different allowance. Under organization billing the plan's 10 GB is the company's: counted
|
|
162
|
+
* against one member's files it becomes 10 GB per member, and a paid quota is bypassed by an invitation.
|
|
163
|
+
* So `tenant` is the organization the ceiling was resolved for, exactly as {@link EntitlementHolder.tenant}
|
|
164
|
+
* is, and a capability sums every row it holds for that tenant, whoever wrote it. `null` means the holder
|
|
165
|
+
* is not a tenant: the ceiling is the caller's own, and the caller's rows are what count.
|
|
166
|
+
*
|
|
167
|
+
* It is a counting key, never a gate. A capability that would write a row for a different tenant than the
|
|
168
|
+
* one the ceiling covers refuses the write — that row would sit outside the sum, and under-counting has no
|
|
169
|
+
* recovery.
|
|
170
|
+
*/
|
|
171
|
+
export type MeterLimit =
|
|
172
|
+
| { readonly enforced: false }
|
|
173
|
+
| { readonly enforced: true; readonly limit: number | null; readonly tenant: string | null };
|
|
174
|
+
|
|
137
175
|
/**
|
|
138
176
|
* How a resolver's holder appears in a denial's `detail` and on the denial's audit row — and **nothing a
|
|
139
177
|
* gate may compare**.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
import type { Logger } from "../logger/logger";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The usage-meter seam — how a capability that writes the rows a meter counts asks whether one more is
|
|
9
|
+
* allowed, when there is no request to ask through.
|
|
10
|
+
*
|
|
11
|
+
* `@pithy-sh/email` enqueues from a route, from a Workflow step and from a cron, and only the first has a
|
|
12
|
+
* `c.var.entitlements`. So the meter it charges is found the way the ledger is found — among the composed
|
|
13
|
+
* capabilities, by a structural predicate — and it is handed the holder explicitly. Nothing here imports the
|
|
14
|
+
* provider: an email capability composed with no payments finds no meter and enqueues as it always did.
|
|
15
|
+
*
|
|
16
|
+
* **A meter refuses; it never bills.** `charge` either records the use or throws the provider's own refusal
|
|
17
|
+
* (`payments/quota_exceeded`, 402). What it counted is never reported to a store — the kit does not sell
|
|
18
|
+
* overage — so this seam has no way to read a count back out.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Who a charge is against: the holder pair, exactly as the provider keys it. */
|
|
22
|
+
export interface MeterSubject {
|
|
23
|
+
/** The kind of holder — `user` or `organization` under `@pithy-sh/payments`. */
|
|
24
|
+
readonly subjectType: string;
|
|
25
|
+
/** The holder's id. */
|
|
26
|
+
readonly subjectId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One charge against one meter. */
|
|
30
|
+
export interface MeterCharge {
|
|
31
|
+
/** Who uses it. */
|
|
32
|
+
readonly subject: MeterSubject;
|
|
33
|
+
/** Which meter — `emails`. */
|
|
34
|
+
readonly meter: string;
|
|
35
|
+
/** How many. A whole number from 1. */
|
|
36
|
+
readonly amount: number;
|
|
37
|
+
/** The clock. Defaults to now. */
|
|
38
|
+
readonly now?: Date;
|
|
39
|
+
/** Where a refused or failed charge is logged. Optional; the provider logs through its own when absent. */
|
|
40
|
+
readonly log?: Logger;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The meter a provider exposes. */
|
|
44
|
+
export interface UsageMeter {
|
|
45
|
+
/** The capability answering — `payments`. */
|
|
46
|
+
readonly provider: string;
|
|
47
|
+
/**
|
|
48
|
+
* Record `amount` of `meter` against `subject`, or refuse. An advisory meter records nothing and refuses
|
|
49
|
+
* nothing. **A charge that cannot be checked refuses**, because under-counting has no recovery.
|
|
50
|
+
*/
|
|
51
|
+
charge(env: Record<string, unknown>, charge: MeterCharge): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A composed capability that provides a meter. */
|
|
55
|
+
export interface UsageMeterProvider extends Capability {
|
|
56
|
+
/** The meter. */
|
|
57
|
+
readonly usageMeter: UsageMeter;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a composed capability provides a usage meter. Structural, so no provider package is imported. */
|
|
61
|
+
export function isUsageMeterProvider(capability: Capability): capability is UsageMeterProvider {
|
|
62
|
+
const meter = (capability as { usageMeter?: { charge?: unknown } }).usageMeter;
|
|
63
|
+
return typeof meter?.charge === "function";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The composed usage meter, or undefined when nothing composed provides one. */
|
|
67
|
+
export function composedUsageMeter(capabilities: readonly Capability[]): UsageMeter | undefined {
|
|
68
|
+
return capabilities.find(isUsageMeterProvider)?.usageMeter;
|
|
69
|
+
}
|