@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.
Files changed (180) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/audit/auditEvent.d.ts +5 -0
  17. package/dist/audit/auditEvent.d.ts.map +1 -1
  18. package/dist/audit/auditEvent.js +2 -0
  19. package/dist/capability/bindings.d.ts +47 -2
  20. package/dist/capability/bindings.d.ts.map +1 -1
  21. package/dist/capability/bindings.js +117 -7
  22. package/dist/capability/capability.d.ts +98 -28
  23. package/dist/capability/capability.d.ts.map +1 -1
  24. package/dist/capability/manifest.d.ts +2 -1
  25. package/dist/capability/manifest.d.ts.map +1 -1
  26. package/dist/capability/manifest.js +1 -1
  27. package/dist/capability/settings.d.ts +86 -2
  28. package/dist/capability/settings.d.ts.map +1 -1
  29. package/dist/capability/settings.js +80 -1
  30. package/dist/cli/commandCatalog.generated.d.ts +184 -22
  31. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  32. package/dist/cli/commandCatalog.generated.js +196 -22
  33. package/dist/cli/commandFacts.d.ts +1 -1
  34. package/dist/cli/commandFacts.d.ts.map +1 -1
  35. package/dist/cli/commandFacts.js +1 -0
  36. package/dist/controlPlane/capability.d.ts.map +1 -1
  37. package/dist/controlPlane/capability.js +12 -2
  38. package/dist/controlPlane/context.d.ts +5 -0
  39. package/dist/controlPlane/context.d.ts.map +1 -1
  40. package/dist/controlPlane/context.js +3 -1
  41. package/dist/controlPlane/data/connection.d.ts +47 -0
  42. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  43. package/dist/controlPlane/data/connection.js +55 -1
  44. package/dist/controlPlane/http/cors.d.ts +4 -4
  45. package/dist/controlPlane/http/cors.js +4 -4
  46. package/dist/controlPlane/http/guard.d.ts +11 -0
  47. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  48. package/dist/controlPlane/http/guard.js +17 -1
  49. package/dist/controlPlane/http/handlers.d.ts +60 -0
  50. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  51. package/dist/controlPlane/http/handlers.js +31 -2
  52. package/dist/controlPlane/http/readiness.d.ts +33 -0
  53. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  54. package/dist/controlPlane/http/readiness.js +39 -0
  55. package/dist/controlPlane/http/responses.d.ts +147 -0
  56. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  57. package/dist/controlPlane/http/responses.js +61 -4
  58. package/dist/controlPlane/http/routes.d.ts +1 -0
  59. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  60. package/dist/controlPlane/http/routes.js +17 -3
  61. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  62. package/dist/controlPlane/http/verify.js +2 -1
  63. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  64. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  65. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  66. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  67. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  68. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  69. package/dist/controlPlane/scope/scope.d.ts +13 -2
  70. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  71. package/dist/controlPlane/scope/scope.js +14 -3
  72. package/dist/createBackend.d.ts.map +1 -1
  73. package/dist/createBackend.js +7 -2
  74. package/dist/createEntrypoint.d.ts.map +1 -1
  75. package/dist/createEntrypoint.js +5 -3
  76. package/dist/entitlement/allowance.d.ts +25 -0
  77. package/dist/entitlement/allowance.d.ts.map +1 -0
  78. package/dist/entitlement/allowance.js +80 -0
  79. package/dist/entitlement/entitlement.d.ts +41 -0
  80. package/dist/entitlement/entitlement.d.ts.map +1 -1
  81. package/dist/entitlement/meter.d.ts +57 -0
  82. package/dist/entitlement/meter.d.ts.map +1 -0
  83. package/dist/entitlement/meter.js +13 -0
  84. package/dist/error/http.d.ts +144 -0
  85. package/dist/error/http.d.ts.map +1 -1
  86. package/dist/error/payload.d.ts +288 -0
  87. package/dist/error/payload.d.ts.map +1 -1
  88. package/dist/error/payload.js +100 -4
  89. package/dist/http/authContext.d.ts +7 -0
  90. package/dist/http/authContext.d.ts.map +1 -1
  91. package/dist/http/authContext.js +8 -1
  92. package/dist/http/scopeGates.d.ts +20 -0
  93. package/dist/http/scopeGates.d.ts.map +1 -0
  94. package/dist/http/scopeGates.js +36 -0
  95. package/dist/index.d.ts +1 -1
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +2 -2
  98. package/dist/logger/record.d.ts +84 -0
  99. package/dist/logger/record.d.ts.map +1 -1
  100. package/dist/migrations/capabilitySets.d.ts +23 -0
  101. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  102. package/dist/migrations/capabilitySets.js +74 -0
  103. package/dist/migrations/registry.d.ts +26 -0
  104. package/dist/migrations/registry.d.ts.map +1 -1
  105. package/dist/migrations/registry.js +38 -7
  106. package/dist/migrations/state.d.ts +60 -0
  107. package/dist/migrations/state.d.ts.map +1 -0
  108. package/dist/migrations/state.js +46 -0
  109. package/dist/naming/feature.d.ts +27 -5
  110. package/dist/naming/feature.d.ts.map +1 -1
  111. package/dist/naming/feature.js +36 -6
  112. package/dist/naming/limits.d.ts +47 -3
  113. package/dist/naming/limits.d.ts.map +1 -1
  114. package/dist/naming/limits.js +47 -3
  115. package/dist/naming/provisionScope.d.ts +6 -0
  116. package/dist/naming/provisionScope.d.ts.map +1 -1
  117. package/dist/naming/provisionScope.js +8 -3
  118. package/dist/naming/resourceNames.d.ts +21 -2
  119. package/dist/naming/resourceNames.d.ts.map +1 -1
  120. package/dist/naming/resourceNames.js +21 -3
  121. package/dist/seed/writeD1.d.ts +7 -1
  122. package/dist/seed/writeD1.d.ts.map +1 -1
  123. package/dist/seed/writeD1.js +9 -3
  124. package/dist/seed/writeKv.d.ts +2 -0
  125. package/dist/seed/writeKv.d.ts.map +1 -1
  126. package/dist/seed/writeKv.js +6 -2
  127. package/dist/version.generated.d.ts +1 -1
  128. package/dist/version.generated.js +1 -1
  129. package/dist/worker/serviceLoopback.d.ts +36 -0
  130. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  131. package/dist/worker/serviceLoopback.js +74 -0
  132. package/dist/workflow/faults.d.ts +12 -1
  133. package/dist/workflow/faults.d.ts.map +1 -1
  134. package/dist/workflow/faults.js +22 -15
  135. package/package.json +2 -2
  136. package/src/access/access.ts +298 -0
  137. package/src/access/acting.ts +59 -0
  138. package/src/access/policyCheck.ts +77 -0
  139. package/src/access/usage.ts +141 -0
  140. package/src/access/visibility.ts +109 -0
  141. package/src/audit/auditEvent.ts +4 -0
  142. package/src/capability/bindings.ts +159 -13
  143. package/src/capability/capability.ts +99 -29
  144. package/src/capability/settings.ts +146 -2
  145. package/src/cli/commandCatalog.generated.ts +212 -22
  146. package/src/cli/commandFacts.ts +1 -0
  147. package/src/controlPlane/capability.ts +25 -1
  148. package/src/controlPlane/context.ts +4 -0
  149. package/src/controlPlane/data/connection.ts +90 -0
  150. package/src/controlPlane/http/cors.ts +4 -4
  151. package/src/controlPlane/http/guard.ts +21 -0
  152. package/src/controlPlane/http/handlers.ts +41 -0
  153. package/src/controlPlane/http/readiness.ts +50 -0
  154. package/src/controlPlane/http/responses.ts +140 -2
  155. package/src/controlPlane/http/routes.ts +25 -1
  156. package/src/controlPlane/http/verify.ts +2 -0
  157. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  158. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  159. package/src/controlPlane/scope/scope.ts +14 -2
  160. package/src/createBackend.ts +24 -6
  161. package/src/createEntrypoint.ts +9 -3
  162. package/src/entitlement/allowance.ts +85 -0
  163. package/src/entitlement/entitlement.ts +38 -0
  164. package/src/entitlement/meter.ts +69 -0
  165. package/src/error/payload.ts +211 -4
  166. package/src/http/authContext.ts +13 -1
  167. package/src/http/scopeGates.ts +34 -0
  168. package/src/index.ts +1 -0
  169. package/src/migrations/capabilitySets.ts +87 -0
  170. package/src/migrations/registry.ts +46 -2
  171. package/src/migrations/state.ts +94 -0
  172. package/src/naming/feature.ts +46 -7
  173. package/src/naming/limits.ts +35 -3
  174. package/src/naming/provisionScope.ts +19 -1
  175. package/src/naming/resourceNames.ts +52 -5
  176. package/src/seed/writeD1.ts +13 -4
  177. package/src/seed/writeKv.ts +6 -2
  178. package/src/version.generated.ts +1 -1
  179. package/src/worker/serviceLoopback.ts +105 -0
  180. package/src/workflow/faults.ts +51 -24
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { MigrationProvider } from "kysely/migration";
4
+ import type { Capability } from "../../capability/capability";
5
+ import type { HealthSummary } from "../discovery/healthSummary";
6
+ import type { MigrationDatabaseView, MigrationView } from "../http/responses";
7
+ /**
8
+ * **Migration state over the control plane (#729).**
9
+ *
10
+ * A management client could not answer what is applied where, or whether a deploy left something behind:
11
+ * the seam registered a ping, the manifest and key rotation, and not one field of it was about a database.
12
+ * The read already existed — {@link readMigrationLedger} answers both directions — and it needs a D1
13
+ * binding and the composed provider in one process. **The Worker is the only thing holding both**, and it
14
+ * is the deployed build, so asked from its side the answer cannot be stale the way the project on disk is.
15
+ *
16
+ * Two halves. At assembly, {@link composeMigrationDatabases} builds what this Worker declares per bound
17
+ * database, through `collectMigrationSets` — the function `pithy migrate` composes with, so the route and
18
+ * `pithy doctor` compose the same way. Not always from the same inputs: on a D1 two Workers share, the CLI
19
+ * merges both Workers' sets and the route holds only its own, so `pending` and `undeclared` can differ
20
+ * there. On a database one Worker binds alone they agree. Per request, {@link readDatabaseMigrations} reads
21
+ * one database for the route and {@link readMigrationHealth} totals all of them for the manifest.
22
+ *
23
+ * **Read-only.** Nothing here runs, reverses or resets a migration. A screen somebody opened to ask must
24
+ * not hand them the thing that changes the schema.
25
+ *
26
+ * **`undeclared` is relative to this Worker.** Two Workers can be bound to one physical D1, and the CLI
27
+ * composes across all of them. A Worker composes only its own capabilities and cannot learn a co-bound
28
+ * Worker's, so from inside one, every migration the other shipped to the shared database reads as
29
+ * undeclared. That is why `undeclaredMigrations` grades nothing.
30
+ */
31
+ /** One database this Worker binds, with what this build declares for it. Built once, at assembly. */
32
+ export interface ComposedMigrationDatabase {
33
+ /** The database name, as capabilities declare it in `databases`. */
34
+ database: string;
35
+ /** The D1 binding it is read through. */
36
+ binding: string;
37
+ /** Every migration this build declares for it — empty for a database bound with none. */
38
+ provider: MigrationProvider;
39
+ /** Each declared migration's namespace, known from the set it came from. */
40
+ namespaces: ReadonlyMap<string, string>;
41
+ }
42
+ /**
43
+ * Every database this Worker binds, with the provider this build composes for it, sorted by name.
44
+ *
45
+ * One entry per database `composeDatabases` maps to a binding — the same map the request registry is built
46
+ * from — so a database bound with no migrations is still reported. Leaving it out would read as not bound.
47
+ */
48
+ export declare function composeMigrationDatabases(capabilities: readonly Capability[]): ComposedMigrationDatabase[];
49
+ /**
50
+ * One migration as the route reports it.
51
+ *
52
+ * A declared migration's namespace comes from the set it was declared in. An undeclared one has only its
53
+ * key, so it is decomposed — and a key no registry composed answers null rather than a guess.
54
+ */
55
+ export declare function migrationView(name: string, namespaces: ReadonlyMap<string, string>): MigrationView;
56
+ /**
57
+ * Read one database's migration state for the route.
58
+ *
59
+ * **A database that cannot be read is `unreadable`, and costs only itself.** The cause goes to
60
+ * `onUnreadable` — the route logs it, where the operator's context stays the operator's — and never into
61
+ * the view: a D1 error can name a table or a query, and the view crosses to a management client.
62
+ */
63
+ export declare function readDatabaseMigrations(env: Record<string, unknown>, entry: ComposedMigrationDatabase, onUnreadable?: (entry: ComposedMigrationDatabase, cause: unknown) => void): Promise<MigrationDatabaseView>;
64
+ /**
65
+ * The two health keys the seam contributes to its own manifest entry.
66
+ *
67
+ * Both `indexed`: one small select per bound database, bounded by the declared migration count rather
68
+ * than by the adopter's data. Both behind {@link MIGRATIONS_READ_SCOPE}, which the seam's own route
69
+ * requires, so a connection without it gets no value rather than a zero.
70
+ *
71
+ * **`undeclaredMigrations` declares no nominal, on purpose.** From inside one Worker, a database shared
72
+ * with another lists the other's migrations as undeclared, so `atMost: 0` would report a corrupted chain
73
+ * on a database that is entirely fine. Null is what a client reads as unknowable, never as healthy (#471).
74
+ */
75
+ export declare const MIGRATION_HEALTH_KEYS: readonly [{
76
+ readonly key: "pendingMigrations";
77
+ readonly kind: "count";
78
+ readonly states: null;
79
+ readonly scope: string;
80
+ readonly cost: "indexed";
81
+ readonly summary: "Migrations this build declares that a bound database has not applied.";
82
+ readonly nominal: {
83
+ readonly atMost: 0;
84
+ };
85
+ }, {
86
+ readonly key: "undeclaredMigrations";
87
+ readonly kind: "count";
88
+ readonly states: null;
89
+ readonly scope: string;
90
+ readonly cost: "indexed";
91
+ readonly summary: "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.";
92
+ readonly nominal: null;
93
+ }];
94
+ /**
95
+ * Total pending and undeclared across every bound database, for the manifest.
96
+ *
97
+ * **Throws rather than total a database it could not read.** A partial total is a zero for the database
98
+ * nobody read. The seam catches the throw and reports the summary `unavailable`, which a client reads as
99
+ * unknown — null is never zero.
100
+ */
101
+ export declare function readMigrationHealth(env: Record<string, unknown>, databases: readonly ComposedMigrationDatabase[]): Promise<HealthSummary>;
102
+ //# sourceMappingURL=migrationState.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrationState.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/migrationState/migrationState.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAQ9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,qBAAqB,EAAsB,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGlG;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,qGAAqG;AACrG,MAAM,WAAW,yBAAyB;IACxC,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,yFAAyF;IACzF,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,4EAA4E;IAC5E,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAKD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,yBAAyB,EAAE,CAe1G;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,aAAa,CAElG;AA8BD;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,KAAK,EAAE,yBAAyB,EAChC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,yBAAyB,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,GACxE,OAAO,CAAC,qBAAqB,CAAC,CAiBhC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB;kBAEzB,mBAAmB;mBAClB,OAAO;;;mBAGP,SAAS;sBACN,uEAAuE;;iBACrE,MAAM,EAAE,CAAC;;;kBAGf,sBAAsB;mBACrB,OAAO;;;mBAGP,SAAS;sBAEb,mKAAmK;;EAGvH,CAAC;AAEnD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,SAAS,EAAE,SAAS,yBAAyB,EAAE,GAC9C,OAAO,CAAC,aAAa,CAAC,CAQxB"}
@@ -0,0 +1,142 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { InternalError } from "../../error/pithyError.js";
4
+ import { MIGRATIONS_READ_SCOPE } from "../scope/scope.js";
5
+ import { composeDatabases } from "../../data/databases.js";
6
+ import { composedMigrationKey, createMigrationRegistry, decomposeMigrationKey } from "../../migrations/registry.js";
7
+ import { collectMigrationSets } from "../../migrations/capabilitySets.js";
8
+ import { readMigrationLedger } from "../../migrations/runner.js";
9
+ import { readMigrationState } from "../../migrations/state.js";
10
+ //#region src/controlPlane/migrationState/migrationState.ts
11
+ /** A provider declaring nothing, for a database bound with no migrations: everything applied is undeclared. */
12
+ const NOTHING_DECLARED = { getMigrations: async () => ({}) };
13
+ /**
14
+ * Every database this Worker binds, with the provider this build composes for it, sorted by name.
15
+ *
16
+ * One entry per database `composeDatabases` maps to a binding — the same map the request registry is built
17
+ * from — so a database bound with no migrations is still reported. Leaving it out would read as not bound.
18
+ */
19
+ function composeMigrationDatabases(capabilities) {
20
+ const sets = collectMigrationSets([...capabilities]);
21
+ const registry = createMigrationRegistry(sets);
22
+ const bound = composeDatabases([...capabilities]);
23
+ return Object.entries(bound).sort(([left], [right]) => left.localeCompare(right)).map(([database, group]) => {
24
+ const namespaces = /* @__PURE__ */ new Map();
25
+ for (const set of sets) {
26
+ if (set.database !== database) continue;
27
+ for (const localKey of Object.keys(set.migrations)) namespaces.set(composedMigrationKey(set, localKey), set.namespace);
28
+ }
29
+ return {
30
+ database,
31
+ binding: group.binding,
32
+ provider: registry[database] ?? NOTHING_DECLARED,
33
+ namespaces
34
+ };
35
+ });
36
+ }
37
+ /**
38
+ * One migration as the route reports it.
39
+ *
40
+ * A declared migration's namespace comes from the set it was declared in. An undeclared one has only its
41
+ * key, so it is decomposed — and a key no registry composed answers null rather than a guess.
42
+ */
43
+ function migrationView(name, namespaces) {
44
+ return {
45
+ name,
46
+ namespace: namespaces.get(name) ?? decomposeMigrationKey(name)?.namespace ?? null
47
+ };
48
+ }
49
+ /** The D1 binding for `entry`, or a refusal naming it — a Worker missing the binding cannot read it. */
50
+ function bindingOf(env, entry) {
51
+ const database = env[entry.binding];
52
+ if (database == null) throw new InternalError({
53
+ message: `The ${entry.binding} binding is missing, so database "${entry.database}" cannot be read.`,
54
+ action: `Declare ${entry.binding} in this Worker's wrangler.jsonc, then deploy again.`
55
+ });
56
+ return database;
57
+ }
58
+ function groupView(group, namespaces) {
59
+ return {
60
+ group: group.group,
61
+ appliedAt: group.appliedAt.toISOString(),
62
+ migrations: group.migrations.map((name) => migrationView(name, namespaces)),
63
+ position: group.position.state === "top" ? { state: "top" } : {
64
+ state: "buried",
65
+ above: group.position.above,
66
+ ungrouped: group.position.ungrouped.map((name) => migrationView(name, namespaces))
67
+ }
68
+ };
69
+ }
70
+ /**
71
+ * Read one database's migration state for the route.
72
+ *
73
+ * **A database that cannot be read is `unreadable`, and costs only itself.** The cause goes to
74
+ * `onUnreadable` — the route logs it, where the operator's context stays the operator's — and never into
75
+ * the view: a D1 error can name a table or a query, and the view crosses to a management client.
76
+ */
77
+ async function readDatabaseMigrations(env, entry, onUnreadable) {
78
+ try {
79
+ const state = await readMigrationState(bindingOf(env, entry), entry.provider);
80
+ const view = (name) => migrationView(name, entry.namespaces);
81
+ return {
82
+ state: "readable",
83
+ database: entry.database,
84
+ binding: entry.binding,
85
+ applied: state.applied.map(view),
86
+ pending: state.pending.map(view),
87
+ undeclared: state.undeclared.map(view),
88
+ lastGroup: state.lastGroup ? groupView(state.lastGroup, entry.namespaces) : null
89
+ };
90
+ } catch (cause) {
91
+ onUnreadable?.(entry, cause);
92
+ return {
93
+ state: "unreadable",
94
+ database: entry.database,
95
+ binding: entry.binding
96
+ };
97
+ }
98
+ }
99
+ /**
100
+ * The two health keys the seam contributes to its own manifest entry.
101
+ *
102
+ * Both `indexed`: one small select per bound database, bounded by the declared migration count rather
103
+ * than by the adopter's data. Both behind {@link MIGRATIONS_READ_SCOPE}, which the seam's own route
104
+ * requires, so a connection without it gets no value rather than a zero.
105
+ *
106
+ * **`undeclaredMigrations` declares no nominal, on purpose.** From inside one Worker, a database shared
107
+ * with another lists the other's migrations as undeclared, so `atMost: 0` would report a corrupted chain
108
+ * on a database that is entirely fine. Null is what a client reads as unknowable, never as healthy (#471).
109
+ */
110
+ const MIGRATION_HEALTH_KEYS = [{
111
+ key: "pendingMigrations",
112
+ kind: "count",
113
+ states: null,
114
+ scope: MIGRATIONS_READ_SCOPE,
115
+ cost: "indexed",
116
+ summary: "Migrations this build declares that a bound database has not applied.",
117
+ nominal: { atMost: 0 }
118
+ }, {
119
+ key: "undeclaredMigrations",
120
+ kind: "count",
121
+ states: null,
122
+ scope: MIGRATIONS_READ_SCOPE,
123
+ cost: "indexed",
124
+ summary: "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.",
125
+ nominal: null
126
+ }];
127
+ /**
128
+ * Total pending and undeclared across every bound database, for the manifest.
129
+ *
130
+ * **Throws rather than total a database it could not read.** A partial total is a zero for the database
131
+ * nobody read. The seam catches the throw and reports the summary `unavailable`, which a client reads as
132
+ * unknown — null is never zero.
133
+ */
134
+ async function readMigrationHealth(env, databases) {
135
+ const ledgers = await Promise.all(databases.map((entry) => readMigrationLedger(bindingOf(env, entry), entry.provider)));
136
+ return {
137
+ pendingMigrations: ledgers.reduce((total, ledger) => total + ledger.pending.length, 0),
138
+ undeclaredMigrations: ledgers.reduce((total, ledger) => total + ledger.undeclared.length, 0)
139
+ };
140
+ }
141
+ //#endregion
142
+ export { MIGRATION_HEALTH_KEYS, composeMigrationDatabases, migrationView, readDatabaseMigrations, readMigrationHealth };
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Migration } from "kysely/migration";
4
+ /**
5
+ * Who sanctioned a connection, what kind of client it is, and what it is for — `#721`.
6
+ *
7
+ * **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by
8
+ * it, so the three columns arrive in a migration of their own, as `0002`'s origin did.
9
+ *
10
+ * **Nullable, with no default, and nothing backfilled.** A row registered before this carries no
11
+ * principal, and none can be recovered: most of the trail that might be mined for one names a token
12
+ * rather than a person, and a principal read off a token name is the self-assertion this issue exists to
13
+ * end. So null means *registered before principals were recorded*, the connection keeps working exactly
14
+ * as it did, its purpose may be set with `pithy dashboard connect --update --purpose`, and its principal
15
+ * is set only by registering again, under a signed-in session.
16
+ *
17
+ * **The Worker reads only `actorKind`**, to stamp it onto the audit rows a connection's calls write. The
18
+ * other two exist for the adopter's own review, on their own side.
19
+ */
20
+ export declare const controlplane_0003_connection_principal: Migration;
21
+ //# sourceMappingURL=0003_connection_principal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"0003_connection_principal.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/migrations/0003_connection_principal.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sCAAsC,EAAE,SAcpD,CAAC"}
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/controlPlane/migrations/0003_connection_principal.ts
4
+ /**
5
+ * Who sanctioned a connection, what kind of client it is, and what it is for — `#721`.
6
+ *
7
+ * **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by
8
+ * it, so the three columns arrive in a migration of their own, as `0002`'s origin did.
9
+ *
10
+ * **Nullable, with no default, and nothing backfilled.** A row registered before this carries no
11
+ * principal, and none can be recovered: most of the trail that might be mined for one names a token
12
+ * rather than a person, and a principal read off a token name is the self-assertion this issue exists to
13
+ * end. So null means *registered before principals were recorded*, the connection keeps working exactly
14
+ * as it did, its purpose may be set with `pithy dashboard connect --update --purpose`, and its principal
15
+ * is set only by registering again, under a signed-in session.
16
+ *
17
+ * **The Worker reads only `actorKind`**, to stamp it onto the audit rows a connection's calls write. The
18
+ * other two exist for the adopter's own review, on their own side.
19
+ */
20
+ const controlplane_0003_connection_principal = {
21
+ up: async (db) => {
22
+ await db.schema.alterTable("pithyControlplaneConnections").addColumn("authorizedBy", "text").execute();
23
+ await db.schema.alterTable("pithyControlplaneConnections").addColumn("actorKind", "text").execute();
24
+ await db.schema.alterTable("pithyControlplaneConnections").addColumn("purpose", "text").execute();
25
+ },
26
+ down: async (db) => {
27
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("purpose").execute();
28
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("actorKind").execute();
29
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("authorizedBy").execute();
30
+ }
31
+ };
32
+ //#endregion
33
+ export { controlplane_0003_connection_principal };
@@ -50,8 +50,19 @@ export declare const MANIFEST_READ_SCOPE: ControlPlaneScope;
50
50
  */
51
51
  export declare const KEYS_ROTATE_SCOPE: ControlPlaneScope;
52
52
  /**
53
- * Every grantable scope the seam's own routes use — what `pithy dashboard connect` offers by default.
54
- * Ping is absent because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
53
+ * Read where each database this Worker binds stands: what is applied, what is pending, what is undeclared,
54
+ * and which run put it there (#729). Read-only by construction — every route behind it is a `GET`, and
55
+ * nothing over the seam runs, reverses or resets a migration. The two migration health keys on the seam's
56
+ * manifest entry sit behind it too, so a connection without it gets no number rather than a zero.
57
+ */
58
+ export declare const MIGRATIONS_READ_SCOPE: ControlPlaneScope;
59
+ /**
60
+ * The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
61
+ * because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
62
+ *
63
+ * **{@link MIGRATIONS_READ_SCOPE} is absent on purpose.** It is a read — every route requiring it is a `GET`
64
+ * — so `connect` derives it from the seam's `adminRoutes` the way it derives every capability's reads. The
65
+ * base exists for `keys:rotate`, which is not a read and would otherwise drop out.
55
66
  */
56
67
  export declare const SEAM_SCOPES: readonly ControlPlaneScope[];
57
68
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/scope/scope.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAexB;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aAQ3B,CAAC;AACJ,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,EAAE,OAAO,MAA2D,CAAC;AAErG;;;;;;;GAOG;AACH,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,GAAG,OAAO,mBAAmB,CAAC;AAErF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,iBAAmC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBAAiC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,WAAW,EAAE,SAAS,iBAAiB,EAA6C,CAAC;AAElG;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAIrH"}
1
+ {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../src/controlPlane/scope/scope.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAexB;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aAQ3B,CAAC;AACJ,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,EAAE,OAAO,MAA2D,CAAC;AAErG;;;;;;;GAOG;AACH,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,GAAG,OAAO,mBAAmB,CAAC;AAErF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,iBAAmC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBAAiC,CAAC;AAElE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,iBAAqC,CAAC;AAE1E;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,EAAE,SAAS,iBAAiB,EAA6C,CAAC;AAElG;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAIrH"}
@@ -40,8 +40,19 @@ const MANIFEST_READ_SCOPE = "manifest:read";
40
40
  */
41
41
  const KEYS_ROTATE_SCOPE = "keys:rotate";
42
42
  /**
43
- * Every grantable scope the seam's own routes use — what `pithy dashboard connect` offers by default.
44
- * Ping is absent because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
43
+ * Read where each database this Worker binds stands: what is applied, what is pending, what is undeclared,
44
+ * and which run put it there (#729). Read-only by construction — every route behind it is a `GET`, and
45
+ * nothing over the seam runs, reverses or resets a migration. The two migration health keys on the seam's
46
+ * manifest entry sit behind it too, so a connection without it gets no number rather than a zero.
47
+ */
48
+ const MIGRATIONS_READ_SCOPE = "migrations:read";
49
+ /**
50
+ * The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
51
+ * because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
52
+ *
53
+ * **{@link MIGRATIONS_READ_SCOPE} is absent on purpose.** It is a read — every route requiring it is a `GET`
54
+ * — so `connect` derives it from the seam's `adminRoutes` the way it derives every capability's reads. The
55
+ * base exists for `keys:rotate`, which is not a read and would otherwise drop out.
45
56
  */
46
57
  const SEAM_SCOPES = [MANIFEST_READ_SCOPE, KEYS_ROTATE_SCOPE];
47
58
  /**
@@ -62,4 +73,4 @@ function scopeCovers(required, presented, granted) {
62
73
  return granted.includes(required);
63
74
  }
64
75
  //#endregion
65
- export { ANY_VERIFIED_CALLER, ControlPlaneScope, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE, SEAM_SCOPES, scopeCovers };
76
+ export { ANY_VERIFIED_CALLER, ControlPlaneScope, KEYS_ROTATE_SCOPE, MANIFEST_READ_SCOPE, MIGRATIONS_READ_SCOPE, SEAM_SCOPES, scopeCovers };
@@ -1 +1 @@
1
- {"version":3,"file":"createBackend.d.ts","sourceRoot":"","sources":["../src/createBackend.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,KAAK,EACV,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,oBAAoB,EAEpB,SAAS,EACV,MAAM,yBAAyB,CAAC;AAMjC,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMtF,OAAO,EAA8B,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAO9C,OAAO,EAA2B,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,oGAAoG;AACpG,MAAM,WAAW,oBAAoB,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EAAE,GAAG,SAAS,UAAU;IAC9F,2EAA2E;IAC3E,YAAY,EAAE,IAAI,CAAC;IACnB;;;OAGG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC;IACV;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,iHAAiH;AACjH,KAAK,UAAU,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,IAAI;IACpD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,GAAG,IAAI,GAAG,WAAW,CAAC,GAAG;QACtD,EAAE,EAAE,UAAU,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,EAAE,EAAE,UAAU,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,SAAS,EAAE,kBAAkB,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC;KAC3D,CAAC;CACH,CAAC;AAuCF,kCAAkC;AAClC,eAAO,MAAM,UAAU;;;iBAQ+B,CAAC;AACvD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CA0LrF"}
1
+ {"version":3,"file":"createBackend.d.ts","sourceRoot":"","sources":["../src/createBackend.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EACV,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,oBAAoB,EAEpB,SAAS,EACV,MAAM,yBAAyB,CAAC;AAOjC,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMtF,OAAO,EAA8B,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAO9C,OAAO,EAA2B,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,oGAAoG;AACpG,MAAM,WAAW,oBAAoB,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EAAE,GAAG,SAAS,UAAU;IAC9F,2EAA2E;IAC3E,YAAY,EAAE,IAAI,CAAC;IACnB;;;OAGG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC;IACV;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,iHAAiH;AACjH,KAAK,UAAU,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,IAAI;IACpD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,GAAG,IAAI,GAAG,WAAW,CAAC,GAAG;QACtD,EAAE,EAAE,UAAU,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,EAAE,EAAE,UAAU,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,SAAS,EAAE,kBAAkB,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC;KAC3D,CAAC;CACH,CAAC;AAuCF,kCAAkC;AAClC,eAAO,MAAM,UAAU;;;iBAQ+B,CAAC;AACvD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CA0MrF"}
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import { noActingOrganization } from "./access/acting.js";
3
4
  import { noopEmit } from "./audit/recorder.js";
4
5
  import { ValidationError } from "./error/pithyError.js";
5
6
  import { pithyCommandOf } from "./cli/command.js";
@@ -8,9 +9,10 @@ import { recordComposition } from "./capability/composition.js";
8
9
  import { validateBindings } from "./capability/validateBindings.js";
9
10
  import { workerVersion } from "./worker/identity.js";
10
11
  import { describeRoute } from "./http/routeSpec.js";
12
+ import { buildDbRegistry, composeDatabases } from "./data/databases.js";
11
13
  import { isControlPlaneCapability } from "./controlPlane/capability.js";
12
14
  import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors.js";
13
- import { buildDbRegistry, composeDatabases } from "./data/databases.js";
15
+ import { registerReadinessGuards } from "./controlPlane/http/readiness.js";
14
16
  import { noEntitlementProvider } from "./entitlement/entitlement.js";
15
17
  import { bakedTranslator } from "./i18n/translator.js";
16
18
  import { catalogFor, composeMessages } from "./i18n/registry.js";
@@ -113,10 +115,11 @@ function createBackend(options) {
113
115
  const app = new Hono();
114
116
  app.onError(loggingErrorHandler);
115
117
  const baseLogger = options.logger ?? createWorkerLogger();
118
+ const adminSurface = corsSurface(all);
116
119
  const controlPlane = all.find(isControlPlaneCapability);
117
120
  if (controlPlane) {
118
121
  const cors = controlPlane.controlPlaneConfig;
119
- registerControlPlaneCors(app, corsSurface(all), allowedOriginSet(cors), cors.corsMaxAgeSeconds);
122
+ registerControlPlaneCors(app, adminSurface, allowedOriginSet(cors), cors.corsMaxAgeSeconds);
120
123
  }
121
124
  let validated = false;
122
125
  app.use("*", async (c, next) => {
@@ -126,6 +129,7 @@ function createBackend(options) {
126
129
  validated = true;
127
130
  }
128
131
  if (c.get("auth") === void 0) c.set("auth", null);
132
+ if (c.get("actingOrganization") === void 0) c.set("actingOrganization", noActingOrganization);
129
133
  if (c.get("locale") === void 0) c.set("locale", null);
130
134
  if (c.get("t") === void 0) c.set("t", defaultTranslator);
131
135
  if (c.get("controlPlane") === void 0) c.set("controlPlane", null);
@@ -188,6 +192,7 @@ function createBackend(options) {
188
192
  routes: app.routes.slice(healthStart)
189
193
  });
190
194
  for (const cap of all) for (const middleware of cap.middleware ?? []) middleware(app);
195
+ registerReadinessGuards(app, adminSurface, all.flatMap((cap) => cap.readiness ?? []));
191
196
  for (const cap of all) {
192
197
  const start = app.routes.length;
193
198
  cap.routes?.(app);
@@ -1 +1 @@
1
- {"version":3,"file":"createEntrypoint.d.ts","sourceRoot":"","sources":["../src/createEntrypoint.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAC3F,OAAO,KAAK,EAAE,UAAU,EAA0B,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,KAAK,oBAAoB,EAAiB,MAAM,iBAAiB,CAAC;AAI3E;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,0DAA0D;IAC1D,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/G;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjH;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzG;AAkBD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,eAAe,CAoD3D"}
1
+ {"version":3,"file":"createEntrypoint.d.ts","sourceRoot":"","sources":["../src/createEntrypoint.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAC3F,OAAO,KAAK,EAAE,UAAU,EAA0B,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,KAAK,oBAAoB,EAAiB,MAAM,iBAAiB,CAAC;AAK3E;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,0DAA0D;IAC1D,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/G;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjH;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzG;AAkBD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,CAAC,IAAI,SAAS,SAAS,UAAU,EAAE,EACxC,KAAK,CAAC,GAAG,SAAS,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EACrF,OAAO,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,eAAe,CAyD3D"}
@@ -3,6 +3,7 @@
3
3
  import { triggerWorkflow } from "./workflow/dispatch.js";
4
4
  import { composeWorkflows, scheduledWorkflows } from "./workflow/register.js";
5
5
  import { createBackend } from "./createBackend.js";
6
+ import { withLocalServices } from "./worker/serviceLoopback.js";
6
7
  //#region src/createEntrypoint.ts
7
8
  /**
8
9
  * The raw MIME stream of a `ForwardableEmailMessage` is **single-use** — once one handler reads it,
@@ -31,17 +32,18 @@ function createEntrypoint(options) {
31
32
  for (const cap of all) cap.boot?.({ capabilities: all });
32
33
  const registry = composeWorkflows(all);
33
34
  const scheduled = scheduledWorkflows(registry);
34
- const entrypoint = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
35
+ const entrypoint = { fetch: (request, env, ctx) => app.fetch(request, withLocalServices(env), ctx) };
35
36
  if (scheduled.length > 0) entrypoint.scheduled = async (_controller, env) => {
36
37
  const failures = [];
37
38
  for (const entry of scheduled) try {
38
- await triggerWorkflow(env, registry, entry.key, {});
39
+ await triggerWorkflow(withLocalServices(env), registry, entry.key, {});
39
40
  } catch (error) {
40
41
  failures.push(error);
41
42
  }
42
43
  if (failures[0] !== void 0) throw failures[0];
43
44
  };
44
- if (handlers.length > 0) entrypoint.email = async (message, env, ctx) => {
45
+ if (handlers.length > 0) entrypoint.email = async (message, given, ctx) => {
46
+ const env = withLocalServices(given);
45
47
  if (handlers.length === 1) {
46
48
  await handlers[0]?.(message, env, ctx);
47
49
  return;
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Context } from "hono";
4
+ import type { PithyHonoEnv } from "../capability/capability";
5
+ import type { MeterLimit } from "./entitlement";
6
+ /** How many of `name` the caller's holder may have: a whole number, or `null` for unlimited. */
7
+ export declare function quota(c: Context<PithyHonoEnv>, name: string): Promise<number | null>;
8
+ /** Whether `name` is on for the caller's holder. Always a boolean. */
9
+ export declare function feature(c: Context<PithyHonoEnv>, name: string): Promise<boolean>;
10
+ /**
11
+ * The ceiling a capability enforces on a meter it counts itself, for the current caller.
12
+ *
13
+ * **No provider, or one that binds nothing to this meter, is `{ enforced: false }`** — and the capability
14
+ * keeps its own configured ceiling. Enforcement is opt-in per meter because a cap that arrives by surprise
15
+ * breaks an adopter who was already over it.
16
+ *
17
+ * **A provider that throws refuses, and logs.** Storage already says which way round to err: over-counting
18
+ * briefly and reclaiming is the right way round, and under-counting has no recovery. Treating a failed read
19
+ * as unenforced would hand a customer an unbounded allowance for as long as the read kept failing. The cost
20
+ * is real and is stated rather than discovered: our bug becomes a refused write for a paying customer.
21
+ * Enforcement being opt-in is what bounds it — an adopter who would rather over-serve leaves the meter
22
+ * advisory.
23
+ */
24
+ export declare function meterLimit(c: Context<PithyHonoEnv>, meter: string): Promise<MeterLimit>;
25
+ //# sourceMappingURL=allowance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"allowance.d.ts","sourceRoot":"","sources":["../../src/entitlement/allowance.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAE7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAqChD,gGAAgG;AAChG,wBAAsB,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAI1F;AAED,sEAAsE;AACtE,wBAAsB,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAItF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,UAAU,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAa7F"}
@@ -0,0 +1,80 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { InternalError, messageOf } from "../error/pithyError.js";
4
+ //#region src/entitlement/allowance.ts
5
+ /**
6
+ * The allowance readers — how much the current caller may have, and whether a switch is on.
7
+ *
8
+ * const seats = await quota(c, "seats"); // number, or null for unlimited
9
+ * if (seats !== null && (await countMembers(orgId)) >= seats) throw new SeatLimitReached(…);
10
+ * if (await feature(c, "auditExport")) { … } // boolean, never null
11
+ *
12
+ * **Neither takes an entitlement key, and that is the point.** A route does not know which tier the
13
+ * caller's holder is on, and during an upgrade overlap it holds two. The provider resolves across every
14
+ * key the holder has and answers the higher, so an adopter compares numbers and never ranks tiers.
15
+ *
16
+ * **Read, never enforced.** A quota denies nothing on its own and a feature set to `false` denies
17
+ * nothing either — `requireEntitlement` still gates on the key alone. What a number means for a request
18
+ * is the adopter's code to decide, because only the adopter can count what it owns.
19
+ *
20
+ * **They live in core for the reason `requireEntitlement` does.** A reader that arrived with
21
+ * `@pithy-sh/payments` would be absent when payments is, and a route reading an allowance must then fail
22
+ * rather than read something. So with no provider composed — or one that declares no allowances — both
23
+ * throw `core/internal` naming the gap. Never `0`, which is a paywall shut for a reason nobody can see;
24
+ * never unlimited, which is a paywall that does not exist.
25
+ */
26
+ /** The refusal when nothing on the request can answer an allowance. The adopter's wiring, so `core/internal`. */
27
+ function noProvider(c, kind, name) {
28
+ const provider = c.var.entitlements?.provider ?? null;
29
+ return new InternalError({
30
+ message: `No ${kind} "${name}" can be resolved here.`,
31
+ action: "Compose a payments capability that declares its entitlements in this Worker's pithy.config.ts.",
32
+ detail: provider === null ? `no entitlement provider is composed, so ${kind}("${name}") has nothing to read.` : `${provider} resolves entitlements and declares no allowances, so ${kind}("${name}") has nothing to read.`
33
+ });
34
+ }
35
+ /** How many of `name` the caller's holder may have: a whole number, or `null` for unlimited. */
36
+ async function quota(c, name) {
37
+ const resolver = c.var.entitlements;
38
+ if (!resolver?.quota) throw noProvider(c, "quota", name);
39
+ return resolver.quota(name);
40
+ }
41
+ /** Whether `name` is on for the caller's holder. Always a boolean. */
42
+ async function feature(c, name) {
43
+ const resolver = c.var.entitlements;
44
+ if (!resolver?.feature) throw noProvider(c, "feature", name);
45
+ return resolver.feature(name);
46
+ }
47
+ /**
48
+ * The ceiling a capability enforces on a meter it counts itself, for the current caller.
49
+ *
50
+ * **No provider, or one that binds nothing to this meter, is `{ enforced: false }`** — and the capability
51
+ * keeps its own configured ceiling. Enforcement is opt-in per meter because a cap that arrives by surprise
52
+ * breaks an adopter who was already over it.
53
+ *
54
+ * **A provider that throws refuses, and logs.** Storage already says which way round to err: over-counting
55
+ * briefly and reclaiming is the right way round, and under-counting has no recovery. Treating a failed read
56
+ * as unenforced would hand a customer an unbounded allowance for as long as the read kept failing. The cost
57
+ * is real and is stated rather than discovered: our bug becomes a refused write for a paying customer.
58
+ * Enforcement being opt-in is what bounds it — an adopter who would rather over-serve leaves the meter
59
+ * advisory.
60
+ */
61
+ async function meterLimit(c, meter) {
62
+ const resolver = c.var.entitlements;
63
+ if (!resolver?.meterLimit) return { enforced: false };
64
+ try {
65
+ return await resolver.meterLimit(meter);
66
+ } catch (error) {
67
+ c.var.log?.error("meter limit resolution failed", {
68
+ provider: resolver.provider,
69
+ meter,
70
+ cause: messageOf(error)
71
+ });
72
+ throw new InternalError({
73
+ message: "Usage could not be checked, so nothing was written.",
74
+ action: "Read the preceding log line for why the allowance read failed.",
75
+ detail: `${resolver.provider} threw resolving the ${meter} meter's ceiling; refusing rather than serving unbounded.`
76
+ });
77
+ }
78
+ }
79
+ //#endregion
80
+ export { feature, meterLimit, quota };
@@ -66,7 +66,48 @@ export interface EntitlementResolver {
66
66
  * See {@link EntitlementHolder} for why this is a label and a tenant rather than the holder itself.
67
67
  */
68
68
  holder?(): Promise<EntitlementHolder | undefined>;
69
+ /**
70
+ * How many of `name` the current caller's holder may have — a whole number, or `null` for unlimited.
71
+ * Optional: a provider with no allowances omits it, and {@link import("./allowance").quota} refuses
72
+ * rather than inventing a number. A provider throws for a name it does not declare, never answers 0.
73
+ */
74
+ quota?(name: string): Promise<number | null>;
75
+ /** Whether `name` is on for the current caller's holder. Never null. Optional, as {@link quota} is. */
76
+ feature?(name: string): Promise<boolean>;
77
+ /**
78
+ * The ceiling a capability that counts its own rows enforces for `meter` — storage's bytes, media's
79
+ * items — or `{ enforced: false }` where the provider binds no ceiling to it. Optional, and absent is
80
+ * `{ enforced: false }`: a capability composed with no provider keeps its own configured behavior.
81
+ */
82
+ meterLimit?(meter: string): Promise<MeterLimit>;
69
83
  }
84
+ /**
85
+ * What a provider says about one meter for the current caller: whether it binds a ceiling at all, and if
86
+ * it does, what the ceiling is and whose rows it covers. `limit: null` is unlimited — an enforced meter on
87
+ * a subject whose plan or override says so.
88
+ *
89
+ * Two states rather than a nullable number, because "this meter is advisory here" and "this subject is
90
+ * unlimited" are different facts and a capability must fall back to its own configured ceiling on the
91
+ * first and never on the second.
92
+ *
93
+ * **An enforced ceiling says which rows it is the whole of**, because a ceiling counted against the wrong
94
+ * rows is a different allowance. Under organization billing the plan's 10 GB is the company's: counted
95
+ * against one member's files it becomes 10 GB per member, and a paid quota is bypassed by an invitation.
96
+ * So `tenant` is the organization the ceiling was resolved for, exactly as {@link EntitlementHolder.tenant}
97
+ * is, and a capability sums every row it holds for that tenant, whoever wrote it. `null` means the holder
98
+ * is not a tenant: the ceiling is the caller's own, and the caller's rows are what count.
99
+ *
100
+ * It is a counting key, never a gate. A capability that would write a row for a different tenant than the
101
+ * one the ceiling covers refuses the write — that row would sit outside the sum, and under-counting has no
102
+ * recovery.
103
+ */
104
+ export type MeterLimit = {
105
+ readonly enforced: false;
106
+ } | {
107
+ readonly enforced: true;
108
+ readonly limit: number | null;
109
+ readonly tenant: string | null;
110
+ };
70
111
  /**
71
112
  * How a resolver's holder appears in a denial's `detail` and on the denial's audit row — and **nothing a
72
113
  * gate may compare**.
@@ -1 +1 @@
1
- {"version":3,"file":"entitlement.d.ts","sourceRoot":"","sources":["../../src/entitlement/entitlement.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA2BxB,eAAO,MAAM,cAAc,aAUxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW;;;;;iBAqBuF,CAAC;AAChH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAGpF;AAED,2GAA2G;AAC3G,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,SAAS,WAAW,EAAE,EAAE,GAAG,EAAE,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAMnG;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,mBAAmB;IAClC,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,wGAAwG;IACxG,IAAI,IAAI,OAAO,CAAC,SAAS,WAAW,EAAE,CAAC,CAAC;IACxC;;;;;OAKG;IACH,MAAM,CAAC,IAAI,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,iBAAiB;IAChC,4FAA4F;IAC5F,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,sGAAsG;IACtG,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,mBAGnC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,uBAAuB,CAAC"}
1
+ {"version":3,"file":"entitlement.d.ts","sourceRoot":"","sources":["../../src/entitlement/entitlement.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA2BxB,eAAO,MAAM,cAAc,aAUxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW;;;;;iBAqBuF,CAAC;AAChH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAGpF;AAED,2GAA2G;AAC3G,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,SAAS,WAAW,EAAE,EAAE,GAAG,EAAE,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAMnG;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,mBAAmB;IAClC,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,wGAAwG;IACxG,IAAI,IAAI,OAAO,CAAC,SAAS,WAAW,EAAE,CAAC,CAAC;IACxC;;;;;OAKG;IACH,MAAM,CAAC,IAAI,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IAClD;;;;OAIG;IACH,KAAK,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,uGAAuG;IACvG,OAAO,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC;;;;OAIG;IACH,UAAU,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;CACjD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,iBAAiB;IAChC,4FAA4F;IAC5F,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,sGAAsG;IACtG,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,mBAGnC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,uBAAuB,CAAC"}