@k2b/cloud 0.48.0 → 0.50.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 (206) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-access-resources.ts +2 -1
  3. package/src/_internal/app-capabilities.ts +73 -0
  4. package/src/_internal/app-event-catalog.ts +1 -1
  5. package/src/_internal/app-queries.ts +1 -1
  6. package/src/_internal/application-package.ts +2 -1
  7. package/src/_internal/capabilities.ts +17 -2
  8. package/src/_internal/define-app.ts +2 -0
  9. package/src/ai/app-skill-store.ts +2 -1
  10. package/src/ai/app-skills.ts +2 -1
  11. package/src/ai/approvals.ts +2 -1
  12. package/src/ai/chat-tasks.ts +2 -1
  13. package/src/ai/credentials.ts +2 -1
  14. package/src/ai/dictation-runtime.ts +1 -1
  15. package/src/ai/dictations.ts +1 -1
  16. package/src/ai/files-store.ts +2 -1
  17. package/src/ai/inference-calls.ts +2 -1
  18. package/src/ai/map-store.ts +1 -1
  19. package/src/ai/map-tool.ts +1 -1
  20. package/src/ai/memories.ts +1 -1
  21. package/src/ai/memory-learning-runs.ts +1 -1
  22. package/src/ai/memory-learning.ts +1 -1
  23. package/src/ai/memory-workflow-evidence.ts +1 -1
  24. package/src/ai/message-queue.ts +2 -1
  25. package/src/ai/migrate.ts +1 -1
  26. package/src/ai/model-access-migrate.ts +1 -1
  27. package/src/ai/model-access.ts +1 -1
  28. package/src/ai/model-catalog.ts +1 -1
  29. package/src/ai/model-delete.ts +1 -1
  30. package/src/ai/model-usage.ts +2 -1
  31. package/src/ai/open-questions.ts +1 -1
  32. package/src/ai/openai-api-slots.ts +2 -1
  33. package/src/ai/prefs.ts +2 -1
  34. package/src/ai/projects.ts +2 -1
  35. package/src/ai/provider-connections.ts +1 -1
  36. package/src/ai/quota-limits.ts +2 -1
  37. package/src/ai/quota-provider.ts +2 -1
  38. package/src/ai/quota-report.ts +2 -1
  39. package/src/ai/quotas-migrate.ts +2 -1
  40. package/src/ai/quotas.ts +2 -1
  41. package/src/ai/request-headers.ts +2 -1
  42. package/src/ai/review-content.ts +2 -1
  43. package/src/ai/skill-search.ts +2 -1
  44. package/src/ai/skills.ts +2 -1
  45. package/src/ai/store.ts +2 -1
  46. package/src/ai/structured-runs.ts +2 -1
  47. package/src/ai/tool-audit.ts +2 -1
  48. package/src/ai/turn-timing.ts +1 -1
  49. package/src/ai/usage.ts +2 -1
  50. package/src/ai/web-provenance.ts +2 -1
  51. package/src/api/admin-ai-quotas.ts +1 -1
  52. package/src/api/admin-ai.ts +1 -1
  53. package/src/api/admin-app-keys.ts +47 -3
  54. package/src/api/admin-core-settings.ts +1 -1
  55. package/src/api/admin-outgoing-mail.ts +1 -1
  56. package/src/api/app-bootstrap.ts +23 -1
  57. package/src/api/app-events.ts +1 -1
  58. package/src/api/auth.ts +1 -1
  59. package/src/api/authenticate-app-request.ts +129 -0
  60. package/src/api/capabilities.ts +79 -5
  61. package/src/api/index.ts +2 -0
  62. package/src/api/me.ts +1 -1
  63. package/src/api/search.bench.ts +1 -1
  64. package/src/app-pwa/service.ts +2 -1
  65. package/src/app-pwa/transaction.ts +4 -2
  66. package/src/capabilities/claims.ts +2 -1
  67. package/src/capabilities/executions.ts +8 -4
  68. package/src/capabilities/geo-client.ts +1 -1
  69. package/src/capabilities/migrate.ts +3 -1
  70. package/src/capabilities/resource-readers.ts +2 -2
  71. package/src/capabilities/server-invoke.ts +45 -18
  72. package/src/capabilities/server.ts +2 -1
  73. package/src/cli/admin/app-keys.ts +25 -3
  74. package/src/config/env.ts +4 -4
  75. package/src/contracts/capabilities.ts +15 -2
  76. package/src/contracts/outgoing-mail.ts +1 -1
  77. package/src/events/app-event-delivery.ts +1 -1
  78. package/src/events/live-delivery.ts +2 -1
  79. package/src/runtime/bootstrap-protocol.ts +11 -4
  80. package/src/runtime/clients.ts +6 -2
  81. package/src/server/index.ts +1 -0
  82. package/src/server/ratelimit.ts +1 -1
  83. package/src/server/services/access.ts +2 -1
  84. package/src/services/account-category-policy.ts +1 -1
  85. package/src/services/account-lifecycle/audit.ts +1 -1
  86. package/src/services/account-lifecycle/index.ts +1 -1
  87. package/src/services/account-lifecycle/ipa-backfill.ts +2 -1
  88. package/src/services/accounts/app.ts +1 -1
  89. package/src/services/accounts/avatar.ts +1 -1
  90. package/src/services/accounts/base-group.ts +1 -1
  91. package/src/services/accounts/directory-shares.ts +1 -1
  92. package/src/services/accounts/duplicate-emails.ts +1 -1
  93. package/src/services/accounts/entities.ts +2 -1
  94. package/src/services/accounts/group-sql.ts +2 -1
  95. package/src/services/accounts/groups.ts +1 -1
  96. package/src/services/accounts/identities.ts +13 -11
  97. package/src/services/accounts/ipa-data.ts +1 -1
  98. package/src/services/accounts/local-groups.ts +2 -1
  99. package/src/services/accounts/machine-group-management.ts +2 -1
  100. package/src/services/accounts/posix.ts +13 -12
  101. package/src/services/accounts/request-policy.ts +2 -1
  102. package/src/services/accounts/switching.ts +1 -1
  103. package/src/services/accounts/users.ts +2 -1
  104. package/src/services/announcements/index.ts +1 -1
  105. package/src/services/app-access-management.ts +2 -1
  106. package/src/services/app-access.ts +2 -1
  107. package/src/services/app-admin-facts.ts +11 -3
  108. package/src/services/app-approval.ts +34 -30
  109. package/src/services/app-bootstrap.ts +104 -21
  110. package/src/services/app-details.ts +3 -2
  111. package/src/services/app-events/accept.ts +5 -5
  112. package/src/services/app-events/admin.ts +15 -15
  113. package/src/services/app-events/declarations.ts +6 -3
  114. package/src/services/app-events/deliver.ts +6 -6
  115. package/src/services/app-events.ts +3 -2
  116. package/src/services/app-keys.ts +42 -11
  117. package/src/services/approvals/content.ts +1 -1
  118. package/src/services/approvals/index.ts +2 -1
  119. package/src/services/approvals/list.ts +1 -1
  120. package/src/services/approvals/migrate.ts +1 -1
  121. package/src/services/audit/index.ts +2 -1
  122. package/src/services/auth-flows/ipa.ts +1 -1
  123. package/src/services/auth-flows/magic-link.ts +1 -1
  124. package/src/services/auth-flows/password-reset.ts +1 -1
  125. package/src/services/auth-flows/proxy-return.ts +1 -1
  126. package/src/services/geo/admin.ts +13 -12
  127. package/src/services/geo/identity.ts +46 -0
  128. package/src/services/geo/ledger.ts +118 -106
  129. package/src/services/geo/migrate.ts +2 -1
  130. package/src/services/geo/route-chain.ts +3 -3
  131. package/src/services/geo/service.ts +4 -24
  132. package/src/services/geoip/index.ts +5 -5
  133. package/src/services/geoip/store.ts +24 -21
  134. package/src/services/help/index.ts +2 -1
  135. package/src/services/help/store.ts +2 -1
  136. package/src/services/identity/app-request.ts +44 -0
  137. package/src/services/identity/constants.ts +1 -0
  138. package/src/services/identity/index.ts +1 -0
  139. package/src/services/identity/invocation-actor.ts +2 -1
  140. package/src/services/identity/invocation-token.ts +112 -0
  141. package/src/services/identity/key-ring.ts +2 -2
  142. package/src/services/identity/resource-readers.ts +1 -1
  143. package/src/services/identity/routes.ts +11 -3
  144. package/src/services/identity/runtime-config.ts +14 -3
  145. package/src/services/identity/workload-auth.ts +2 -0
  146. package/src/services/identity/workload-runtime.ts +1 -1
  147. package/src/services/ipa/groups.ts +1 -1
  148. package/src/services/ipa/profile.ts +1 -1
  149. package/src/services/ipa/search.ts +1 -1
  150. package/src/services/ipa/sync.ts +1 -1
  151. package/src/services/ipa/users.ts +1 -1
  152. package/src/services/known-browsers.ts +2 -1
  153. package/src/services/legal-consent.ts +1 -1
  154. package/src/services/logging/index.ts +6 -3
  155. package/src/services/logging/trace.ts +1 -1
  156. package/src/services/logging/web-vitals.ts +1 -1
  157. package/src/services/mandates/index.ts +2 -1
  158. package/src/services/notifications/batches.ts +1 -1
  159. package/src/services/notifications/browser.ts +1 -1
  160. package/src/services/notifications/catalog.ts +1 -1
  161. package/src/services/notifications/dispatcher.ts +1 -1
  162. package/src/services/notifications/index.ts +1 -1
  163. package/src/services/notifications/observability.ts +2 -1
  164. package/src/services/notifications/platform.ts +1 -1
  165. package/src/services/notifications/quiet.ts +1 -1
  166. package/src/services/notifications/user.ts +1 -1
  167. package/src/services/oauth-tokens.ts +1 -1
  168. package/src/services/outbox.ts +15 -14
  169. package/src/services/outgoing-mail/admin.ts +1 -1
  170. package/src/services/outgoing-mail/dispatcher.ts +1 -1
  171. package/src/services/outgoing-mail/drain.ts +2 -1
  172. package/src/services/outgoing-mail/enqueue.ts +2 -1
  173. package/src/services/outgoing-mail/messages.ts +2 -1
  174. package/src/services/outgoing-mail/retention.ts +1 -1
  175. package/src/services/outgoing-mail/send.ts +1 -1
  176. package/src/services/outgoing-mail/store.ts +2 -1
  177. package/src/services/panel-context-consent.ts +32 -28
  178. package/src/services/providers/local/auth.ts +1 -1
  179. package/src/services/providers/local/users.ts +1 -1
  180. package/src/services/pwa-devices.ts +2 -1
  181. package/src/services/rail-preferences.ts +16 -12
  182. package/src/services/rail-shortcuts.ts +12 -10
  183. package/src/services/request-cache-redis.ts +3 -3
  184. package/src/services/service-account-credentials.ts +2 -1
  185. package/src/services/service-accounts.ts +2 -1
  186. package/src/services/session/index.ts +2 -1
  187. package/src/services/session/user.ts +2 -1
  188. package/src/services/settings/index.ts +1 -1
  189. package/src/services/settings/store.ts +1 -1
  190. package/src/services/settings/versions.ts +2 -1
  191. package/src/services/weather/forecast.ts +2 -1
  192. package/src/services/weather/locations.ts +1 -1
  193. package/src/services/weather/migrate.ts +2 -1
  194. package/src/services/weather/short-id.ts +2 -1
  195. package/src/services/webauthn.ts +1 -1
  196. package/src/shared-text/documents.ts +2 -2
  197. package/src/ssr/compact-shell.browser-harness.ts +2 -0
  198. package/src/workflows/ai/store.ts +2 -1
  199. package/src/workflows/store/budget.ts +2 -1
  200. package/src/workflows/store/definitions.ts +2 -1
  201. package/src/workflows/store/events.ts +2 -1
  202. package/src/workflows/store/observability.ts +2 -1
  203. package/src/workflows/store/retention.ts +2 -1
  204. package/src/workflows/store/runs.ts +2 -1
  205. package/src/workflows/store/transaction.ts +2 -1
  206. package/src/workflows/store/worker.ts +2 -1
@@ -1,5 +1,5 @@
1
- import { sql } from "bun";
2
1
  import type { User } from "../../contracts/shared";
2
+ import { sql } from "../../runtime/clients";
3
3
  import { isAccountCategoryAllowed } from "../account-category-policy";
4
4
  import { accounts } from "../accounts";
5
5
  import { logger } from "../logging";
@@ -1,6 +1,6 @@
1
- import { redis, sql } from "bun";
2
1
  import { accountCategory } from "../../contracts/account-categories";
3
2
  import type { User } from "../../contracts/shared";
3
+ import { redis, sql } from "../../runtime/clients";
4
4
  import { createAuthLoginUrl } from "../../shared/redirect";
5
5
  import { isAccountCategoryAllowed } from "../account-category-policy";
6
6
  import { accounts } from "../accounts";
@@ -1,5 +1,5 @@
1
- import { redis, sql } from "bun";
2
1
  import type { User } from "../../contracts/shared";
2
+ import { redis, sql } from "../../runtime/clients";
3
3
  import { createAuthPasswordResetUrl } from "../../shared/redirect";
4
4
  import { isAccountCategoryAllowed } from "../account-category-policy";
5
5
  import { getFreeIpaConfig } from "../freeipa-config";
@@ -1,4 +1,4 @@
1
- import { redis } from "bun";
1
+ import { redis } from "../../runtime/clients";
2
2
 
3
3
  const KEY_PREFIX = "auth:proxy-return:";
4
4
  const DEFAULT_TTL_SECONDS = 300;
@@ -1,4 +1,4 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import { HTTPException } from "hono/http-exception";
3
3
  import {
4
4
  GEO_USAGE_GROUPS,
@@ -12,6 +12,7 @@ import {
12
12
  geoKeyProblem,
13
13
  validateGeoBudget,
14
14
  } from "../../contracts/geo-admin";
15
+ import { sql } from "../../runtime/clients";
15
16
  import { audit } from "../audit";
16
17
  import { toPgTextArray } from "../postgres";
17
18
  import * as settings from "../settings";
@@ -83,7 +84,7 @@ export const verifyGeoapifyKey =
83
84
  };
84
85
 
85
86
  export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () => Date } = {}) => {
86
- const db = options.db ?? sql;
87
+ const db = () => options.db ?? sql;
87
88
  const verify = options.verify ?? verifyGeoapifyKey();
88
89
  const now = options.now ?? (() => new Date());
89
90
 
@@ -92,7 +93,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
92
93
  * carries that transaction's exact timestamp, and so does every row that event wrote.
93
94
  */
94
95
  const changedBy = async (action: string, keys: readonly string[]): Promise<GeoAdminPerson | null> => {
95
- const [row] = await db<{ actor_user_id: string; name: string | null }[]>`
96
+ const [row] = await db()<{ actor_user_id: string; name: string | null }[]>`
96
97
  SELECT e.actor_user_id, COALESCE(NULLIF(u.display_name, ''), u.uid) AS name
97
98
  FROM audit.events e LEFT JOIN auth.users u ON u.id = e.actor_user_id
98
99
  WHERE e.action = ${action} AND e.actor_user_id IS NOT NULL
@@ -111,7 +112,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
111
112
  const [config, states, rows, keyBy, budgetBy] = await Promise.all([
112
113
  readGeoConfig(),
113
114
  settings.readStates([KEY, ...BUDGET_KEYS]),
114
- db<{ key: string; updated_at: Date }[]>`
115
+ db()<{ key: string; updated_at: Date }[]>`
115
116
  SELECT key, updated_at FROM settings.entries WHERE key = ANY(${toPgTextArray([KEY, ...BUDGET_KEYS])}::text[])`,
116
117
  changedBy(KEY_ACTION, [KEY]),
117
118
  changedBy(BUDGET_ACTION, BUDGET_KEYS),
@@ -147,15 +148,15 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
147
148
  const periods = geoPeriods(now(), config.timezone);
148
149
  const plan = config.plan === "custom" ? { dailyCredits: config.customDailyCredits } : GEO_PLANS[config.plan];
149
150
  const [[month], [today], shares, [overruns]] = await Promise.all([
150
- db<
151
+ db()<
151
152
  { used: number }[]
152
153
  >`SELECT used::float8 AS used FROM geo.usage WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
153
- db<{ used: number; at_limit: number }[]>`
154
- SELECT COALESCE(sum(used), 0)::float8 AS used, count(*) FILTER (WHERE used >= ${config.personDailyCredits * 1000})::int AS at_limit
154
+ db()<{ used: number; at_limit: number }[]>`
155
+ SELECT COALESCE(sum(used), 0)::float8 AS used, count(*) FILTER (WHERE used >= ${config.personDailyCredits * 1000} AND subject NOT LIKE 'app:%')::int AS at_limit
155
156
  FROM geo.usage WHERE scope = 'person' AND period = ${periods.person.period}`,
156
- db<{ function_group: string; used: number }[]>`
157
+ db()<{ function_group: string; used: number }[]>`
157
158
  SELECT function_group, used::float8 AS used FROM geo.shares WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
158
- db<{ count: number }[]>`SELECT count(*)::int AS count FROM audit.events
159
+ db()<{ count: number }[]>`SELECT count(*)::int AS count FROM audit.events
159
160
  WHERE action = 'core.geo.route_cost_overrun' AND metadata->>'period' = ${periods.cloud.period}`,
160
161
  ]);
161
162
  return {
@@ -193,7 +194,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
193
194
  await verify(apiKey, signal);
194
195
  signal?.throwIfAborted();
195
196
  const before = (await settings.readStates([KEY])).get(KEY)?.stored ?? "none";
196
- await db.begin(async (tx) => {
197
+ await db().begin(async (tx) => {
197
198
  // The person replaces whatever is stored, including a value this installation cannot read.
198
199
  await settings.set(KEY, apiKey, tx, { replaceUnreadable: true });
199
200
  await audit.record(
@@ -206,7 +207,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
206
207
  },
207
208
  async removeKey(actor: Actor): Promise<GeoAdminStatus> {
208
209
  requireAdmin(actor);
209
- await db.begin(async (tx) => {
210
+ await db().begin(async (tx) => {
210
211
  const [row] = await tx<{ key: string }[]>`DELETE FROM settings.entries WHERE key = ${KEY} RETURNING key`;
211
212
  if (row) await audit.record({ action: "core.geo.key_removed", outcome: "allowed", actor: auditActor(actor) }, tx);
212
213
  });
@@ -230,7 +231,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
230
231
  // resets their unreadable values to the defaults instead of leaving geo off behind a field nobody sees.
231
232
  const inactive = BUDGET_FIELDS.flatMap(([, name]) => (name in updates ? [] : [name]));
232
233
  const resets: string[] = [];
233
- await db.begin(async (tx) => {
234
+ await db().begin(async (tx) => {
234
235
  // Same lock and final-pair rule as every other writer of these settings.
235
236
  await assertGeoBudgetPair(tx, updates, []);
236
237
  // Saving the form is the explicit replacement of a value this installation cannot read.
@@ -0,0 +1,46 @@
1
+ import type { AppPwaActor } from "../../app-pwa/contracts";
2
+ import type { AccessSubject, CapabilityAppExecutionContext, RequestActor } from "../../contracts";
3
+ import { GeoError } from "./errors";
4
+
5
+ export type GeoContext = {
6
+ actor?: RequestActor | CapabilityAppExecutionContext["actor"];
7
+ accessSubject?: AccessSubject | { type: "none" };
8
+ locale?: string;
9
+ signal?: AbortSignal;
10
+ requestId?: string;
11
+ };
12
+ export type GeoBudgetSubject = AccessSubject | { type: "app"; appId: string };
13
+ export type AppPwaGeoContext = { actor: AppPwaActor; locale?: string; signal?: AbortSignal; requestId?: string };
14
+ export type GeoCallContext = GeoContext | AppPwaGeoContext;
15
+ export const geoIdentity = (
16
+ context: GeoContext,
17
+ ): {
18
+ actor: RequestActor | CapabilityAppExecutionContext["actor"];
19
+ accessSubject: GeoBudgetSubject;
20
+ } => {
21
+ const { actor, accessSubject } = context;
22
+ if (actor?.kind === "app" && actor.keyId && accessSubject?.type === "none")
23
+ return { actor, accessSubject: { type: "app", appId: actor.appId } };
24
+ if (
25
+ !actor ||
26
+ actor.kind === "app" ||
27
+ !accessSubject ||
28
+ accessSubject.type === "none" ||
29
+ !(actor.kind === "user" ? actor.user?.id : actor.serviceAccount?.id) ||
30
+ !(accessSubject.type === "user" ? accessSubject.userId : accessSubject.serviceAccountId)
31
+ )
32
+ throw new GeoError("GEO_FORBIDDEN");
33
+ return { actor, accessSubject };
34
+ };
35
+ export const geoCallIdentity = (
36
+ context: GeoCallContext,
37
+ ): {
38
+ actor: RequestActor | CapabilityAppExecutionContext["actor"] | AppPwaActor;
39
+ accessSubject: GeoBudgetSubject;
40
+ } => {
41
+ if (context.actor?.kind === "device" || context.actor?.kind === "visit") {
42
+ if (!context.actor.ownerUserId) throw new GeoError("GEO_FORBIDDEN");
43
+ return { actor: context.actor, accessSubject: { type: "user", userId: context.actor.ownerUserId } };
44
+ }
45
+ return geoIdentity({ actor: context.actor, accessSubject: "accessSubject" in context ? context.accessSubject : undefined });
46
+ };
@@ -1,8 +1,10 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import type { AppPwaActor } from "../../app-pwa/contracts";
3
- import type { AccessSubject, CapabilityExecutionContext } from "../../contracts";
3
+ import type { CapabilityAppExecutionContext, CapabilityExecutionContext } from "../../contracts";
4
+ import { sql } from "../../runtime/clients";
4
5
  import { audit } from "../audit";
5
6
  import { GeoError } from "./errors";
7
+ import type { GeoBudgetSubject } from "./identity";
6
8
  import type { GeoConfig } from "./settings";
7
9
 
8
10
  export type GeoBudgetConfig = Pick<GeoConfig, "timezone" | "monthlyCredits" | "personDailyCredits">;
@@ -30,126 +32,136 @@ export const geoPeriods = (now: Date, timezone: string): { person: GeoPeriod; cl
30
32
  cloud: { period: day.slice(0, 7), resetsAt: boundary(32 * 86400_000, 7) },
31
33
  };
32
34
  };
33
- export const geoSubjectKey = (subject: AccessSubject) =>
34
- subject.type === "user" ? `user:${subject.userId}` : `service_account:${subject.serviceAccountId}`;
35
+ export const geoSubjectKey = (subject: GeoBudgetSubject) =>
36
+ subject.type === "app"
37
+ ? `app:${subject.appId}`
38
+ : subject.type === "user"
39
+ ? `user:${subject.userId}`
40
+ : `service_account:${subject.serviceAccountId}`;
35
41
  export type GeoReservation = {
36
42
  cost: number;
37
43
  group: string;
38
44
  rows: Array<{ scope: "cloud" | "person"; subject: string; period: string; resetsAt: string; limit: number }>;
39
45
  };
40
- const rowsFor = (subject: AccessSubject, config: GeoBudgetConfig, now: Date) => {
46
+ const rowsFor = (subject: GeoBudgetSubject, config: GeoBudgetConfig, now: Date) => {
41
47
  const periods = geoPeriods(now, config.timezone);
42
48
  return [
43
49
  { scope: "cloud", subject: "*", ...periods.cloud, limit: config.monthlyCredits * 1000 },
44
50
  { scope: "person", subject: geoSubjectKey(subject), ...periods.person, limit: config.personDailyCredits * 1000 },
45
51
  ] satisfies GeoReservation["rows"];
46
52
  };
47
- export const createGeoLedger = (db: SQL = sql) => ({
48
- async reserve(subject: AccessSubject, config: GeoBudgetConfig, cost: number, group: string, now: Date): Promise<GeoReservation> {
49
- if (!Number.isSafeInteger(cost) || cost < 0) throw new Error("Invalid geo reservation");
50
- const rows = rowsFor(subject, config, now);
51
- await db.begin(async (tx) => {
52
- // Fixed order across upserts, updates, shares and settlement: Cloud first, person second.
53
- for (const row of rows)
54
- await tx`INSERT INTO geo.usage(scope,subject,period) VALUES(${row.scope},${row.subject},${row.period}) ON CONFLICT DO NOTHING`;
55
- for (const row of rows) {
56
- // Keep the refusal's available/shortfall numbers in the same row-lock snapshot as admission.
57
- const [balance] = await tx<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
53
+ export const createGeoLedger = (injectedDb?: SQL) => {
54
+ const db = () => injectedDb ?? sql;
55
+ return {
56
+ async reserve(subject: GeoBudgetSubject, config: GeoBudgetConfig, cost: number, group: string, now: Date): Promise<GeoReservation> {
57
+ if (!Number.isSafeInteger(cost) || cost < 0) throw new Error("Invalid geo reservation");
58
+ const rows = rowsFor(subject, config, now);
59
+ await db().begin(async (tx) => {
60
+ // Fixed order across upserts, updates, shares and settlement: Cloud first, person second.
61
+ for (const row of rows)
62
+ await tx`INSERT INTO geo.usage(scope,subject,period) VALUES(${row.scope},${row.subject},${row.period}) ON CONFLICT DO NOTHING`;
63
+ for (const row of rows) {
64
+ // Keep the refusal's available/shortfall numbers in the same row-lock snapshot as admission.
65
+ const [balance] = await tx<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
58
66
  WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period} FOR UPDATE`;
59
- const changed = await tx`UPDATE geo.usage SET used=used+${cost} WHERE scope=${row.scope} AND subject=${row.subject}
67
+ const changed = await tx`UPDATE geo.usage SET used=used+${cost} WHERE scope=${row.scope} AND subject=${row.subject}
60
68
  AND period=${row.period} AND used+${cost}<=${row.limit} RETURNING used`;
61
- if (!changed.length) {
62
- const available = row.limit - (balance?.used ?? 0);
63
- throw new GeoError("GEO_BUDGET_EXHAUSTED", {
64
- scope: row.scope,
65
- resetsAt: row.resetsAt,
66
- requiredCredits: cost / 1000,
67
- availableCredits: available / 1000,
68
- shortfallCredits: (cost - available) / 1000,
69
- });
69
+ if (!changed.length) {
70
+ const available = row.limit - (balance?.used ?? 0);
71
+ throw new GeoError("GEO_BUDGET_EXHAUSTED", {
72
+ scope: row.scope,
73
+ resetsAt: row.resetsAt,
74
+ requiredCredits: cost / 1000,
75
+ availableCredits: available / 1000,
76
+ shortfallCredits: (cost - available) / 1000,
77
+ });
78
+ }
70
79
  }
71
- }
72
- for (const row of rows)
73
- await tx`INSERT INTO geo.shares(scope,subject,period,function_group,used)
80
+ for (const row of rows)
81
+ await tx`INSERT INTO geo.shares(scope,subject,period,function_group,used)
74
82
  VALUES(${row.scope},${row.subject},${row.period},${group},${cost})
75
83
  ON CONFLICT(scope,subject,period,function_group) DO UPDATE SET used=geo.shares.used+EXCLUDED.used`;
76
- });
77
- return { cost, group, rows };
78
- },
79
- async settle(reservation: GeoReservation, referenceCost: number) {
80
- const refund = reservation.cost - referenceCost;
81
- if (!Number.isSafeInteger(referenceCost) || referenceCost < 0 || !Number.isSafeInteger(refund))
82
- throw new Error("Invalid geo settlement");
83
- if (!refund) return;
84
- // Negative refunds truthfully debit known routing overruns. Admission still uses the original
85
- // bounded CAS transaction; future reservations see the overdrawn counters and are refused.
86
- // Settle once per reservation, in the same fixed row order as admission.
87
- await db.begin(async (tx) => {
88
- for (const row of reservation.rows) {
89
- await tx`UPDATE geo.usage SET used=used-${refund} WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period}`;
90
- await tx`UPDATE geo.shares SET used=used-${refund} WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period} AND function_group=${reservation.group}`;
91
- }
92
- });
93
- },
94
- async availability(subject: AccessSubject, config: GeoBudgetConfig, now: Date) {
95
- const balances = [];
96
- for (const row of rowsFor(subject, config, now)) {
97
- const [used] = await db<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
84
+ });
85
+ return { cost, group, rows };
86
+ },
87
+ async settle(reservation: GeoReservation, referenceCost: number) {
88
+ const refund = reservation.cost - referenceCost;
89
+ if (!Number.isSafeInteger(referenceCost) || referenceCost < 0 || !Number.isSafeInteger(refund))
90
+ throw new Error("Invalid geo settlement");
91
+ if (!refund) return;
92
+ // Negative refunds truthfully debit known routing overruns. Admission still uses the original
93
+ // bounded CAS transaction; future reservations see the overdrawn counters and are refused.
94
+ // Settle once per reservation, in the same fixed row order as admission.
95
+ await db().begin(async (tx) => {
96
+ for (const row of reservation.rows) {
97
+ await tx`UPDATE geo.usage SET used=used-${refund} WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period}`;
98
+ await tx`UPDATE geo.shares SET used=used-${refund} WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period} AND function_group=${reservation.group}`;
99
+ }
100
+ });
101
+ },
102
+ async availability(subject: GeoBudgetSubject, config: GeoBudgetConfig, now: Date) {
103
+ const balances = [];
104
+ for (const row of rowsFor(subject, config, now)) {
105
+ const [used] = await db()<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
98
106
  WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period}`;
99
- balances.push({ exhausted: (used?.used ?? 0) >= row.limit, resetsAt: row.resetsAt });
100
- }
101
- return { cloud: balances[0]!, person: balances[1]! };
102
- },
103
- async recordTransition(
104
- event: "provider_key_rejected" | "monthly_budget_exhausted" | "route_cost_overrun",
105
- timezone: string,
106
- context: { actor: CapabilityExecutionContext["actor"] | AppPwaActor; requestId?: string },
107
- now: Date,
108
- metadata: Record<string, number> = {},
109
- ) {
110
- const period = geoPeriods(now, timezone).cloud.period;
111
- await db.begin(async (tx) => {
112
- const inserted =
113
- event === "route_cost_overrun"
114
- ? [true]
115
- : await tx`INSERT INTO geo.audit_transitions(event,period) VALUES(${event},${period}) ON CONFLICT DO NOTHING RETURNING event`;
116
- if (!inserted.length) return;
117
- const actor =
118
- context.actor.kind === "device" || context.actor.kind === "visit"
119
- ? null
120
- : context.actor.kind === "user"
121
- ? {
122
- userId: context.actor.user.id,
123
- uid: context.actor.user.uid,
124
- provider: context.actor.user.provider,
125
- roles: context.actor.user.roles,
126
- }
127
- : { uid: `service-account:${context.actor.serviceAccount.id}`, provider: "service_account", roles: context.actor.scopes };
128
- await audit.record(
129
- {
130
- action: `core.geo.${event}`,
131
- outcome: event === "monthly_budget_exhausted" ? "denied" : "failed",
132
- actor,
133
- requestId: context.requestId,
134
- metadata: {
135
- period,
136
- ...metadata,
137
- ...(context.actor.kind === "device" || context.actor.kind === "visit"
138
- ? {
139
- appId: context.actor.appId,
140
- subject: { type: "user", userId: context.actor.ownerUserId },
141
- via: {
142
- kind: context.actor.kind,
107
+ balances.push({ exhausted: (used?.used ?? 0) >= row.limit, resetsAt: row.resetsAt });
108
+ }
109
+ return { cloud: balances[0]!, person: balances[1]! };
110
+ },
111
+ async recordTransition(
112
+ event: "provider_key_rejected" | "monthly_budget_exhausted" | "route_cost_overrun",
113
+ timezone: string,
114
+ context: { actor: CapabilityExecutionContext["actor"] | CapabilityAppExecutionContext["actor"] | AppPwaActor; requestId?: string },
115
+ now: Date,
116
+ metadata: Record<string, number> = {},
117
+ ) {
118
+ const period = geoPeriods(now, timezone).cloud.period;
119
+ await db().begin(async (tx) => {
120
+ const inserted =
121
+ event === "route_cost_overrun"
122
+ ? [true]
123
+ : await tx`INSERT INTO geo.audit_transitions(event,period) VALUES(${event},${period}) ON CONFLICT DO NOTHING RETURNING event`;
124
+ if (!inserted.length) return;
125
+ const actor =
126
+ context.actor.kind === "device" || context.actor.kind === "visit"
127
+ ? null
128
+ : context.actor.kind === "app"
129
+ ? { uid: `app:${context.actor.appId}`, provider: "app", roles: [] }
130
+ : context.actor.kind === "user"
131
+ ? {
132
+ userId: context.actor.user.id,
133
+ uid: context.actor.user.uid,
134
+ provider: context.actor.user.provider,
135
+ roles: context.actor.user.roles,
136
+ }
137
+ : { uid: `service-account:${context.actor.serviceAccount.id}`, provider: "service_account", roles: context.actor.scopes };
138
+ await audit.record(
139
+ {
140
+ action: `core.geo.${event}`,
141
+ outcome: event === "monthly_budget_exhausted" ? "denied" : "failed",
142
+ actor,
143
+ requestId: context.requestId,
144
+ metadata: {
145
+ period,
146
+ ...metadata,
147
+ ...(context.actor.kind === "app" ? { appId: context.actor.appId, keyId: context.actor.keyId } : {}),
148
+ ...(context.actor.kind === "device" || context.actor.kind === "visit"
149
+ ? {
143
150
  appId: context.actor.appId,
144
- deviceId: context.actor.deviceId,
145
- ...(context.actor.kind === "visit" ? { visitId: context.actor.visitId } : {}),
146
- },
147
- }
148
- : {}),
151
+ subject: { type: "user", userId: context.actor.ownerUserId },
152
+ via: {
153
+ kind: context.actor.kind,
154
+ appId: context.actor.appId,
155
+ deviceId: context.actor.deviceId,
156
+ ...(context.actor.kind === "visit" ? { visitId: context.actor.visitId } : {}),
157
+ },
158
+ }
159
+ : {}),
160
+ },
149
161
  },
150
- },
151
- tx,
152
- );
153
- });
154
- },
155
- });
162
+ tx,
163
+ );
164
+ });
165
+ },
166
+ };
167
+ };
@@ -1,4 +1,5 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
+ import { sql } from "../../runtime/clients";
2
3
  export const migrate = async (db: SQL = sql): Promise<void> => {
3
4
  await db.begin(async (tx) => {
4
5
  await tx`SELECT pg_advisory_xact_lock(hashtextextended('core.geo.migrations', 0))`;
@@ -6,9 +6,9 @@ import type { createGeoCache } from "./cache";
6
6
  import { geoCacheKey } from "./cache";
7
7
  import { geoReferenceCost } from "./costs";
8
8
  import { GeoError, GeoProviderError, limitExceeded } from "./errors";
9
+ import { type GeoContext, geoIdentity } from "./identity";
9
10
  import type { createGeoLedger } from "./ledger";
10
11
  import { geoRouteReferenceCost } from "./route-costs";
11
- import type { GeoContext } from "./service";
12
12
  import { type GeoConfig, geoPlan } from "./settings";
13
13
 
14
14
  export type GeoRouteChainDependencies = {
@@ -42,14 +42,14 @@ export const runGeoRouteChain = async <T>(
42
42
  work: (call: GeoRouteChainCall) => Promise<T>,
43
43
  ): Promise<T> => {
44
44
  const { ledger, bucket, cache, now } = dependencies;
45
- if (!context.accessSubject) throw new GeoError("GEO_FORBIDDEN");
45
+ const { accessSubject } = geoIdentity(context);
46
46
  context.signal?.throwIfAborted();
47
47
  const maximumCredits = Math.min(config.personDailyCredits, config.monthlyCredits);
48
48
  if (reserveCost > maximumCredits * 1000) limitExceeded("reservationCredits", maximumCredits, reserveCost / 1000);
49
49
  let reservation: Awaited<ReturnType<typeof ledger.reserve>>;
50
50
  try {
51
51
  // One atomic admission for all known geocodes and every possible paid routing step.
52
- reservation = await ledger.reserve(context.accessSubject, config, reserveCost, "route", now());
52
+ reservation = await ledger.reserve(accessSubject, config, reserveCost, "route", now());
53
53
  } catch (error) {
54
54
  if (error instanceof GeoError && error.code === "GEO_BUDGET_EXHAUSTED" && error.details.scope === "cloud")
55
55
  await dependencies.recordTransition("monthly_budget_exhausted", config.timezone, context);
@@ -1,6 +1,4 @@
1
1
  import type { z } from "zod";
2
- import type { AppPwaActor } from "../../app-pwa/contracts";
3
- import type { AccessSubject, CapabilityExecutionContext, RequestActor } from "../../contracts";
4
2
  import {
5
3
  GeoAutocompleteDataSchema,
6
4
  type GeoAutocompleteInput,
@@ -26,28 +24,10 @@ import { cutGeoWindow, decodePolyline6, locateOnGeoLine, simplifyGeoLine } from
26
24
  import { createGeoRouteService } from "./route-service";
27
25
  import { type GeoConfig, geoPlan, readGeoConfig } from "./settings";
28
26
 
29
- export type GeoContext = Partial<Pick<CapabilityExecutionContext, "actor" | "accessSubject" | "locale" | "signal" | "requestId">>;
30
- /** Internal, narrow App-PWA autocomplete adapter; never a RequestActor or capability caller. */
31
- export type AppPwaGeoContext = { actor: AppPwaActor; locale?: string; signal?: AbortSignal; requestId?: string };
32
- type GeoCallContext = GeoContext | AppPwaGeoContext;
33
- export const geoIdentity = (context: GeoContext): { actor: RequestActor; accessSubject: AccessSubject } => {
34
- const { actor, accessSubject } = context;
35
- if (
36
- !actor ||
37
- !accessSubject ||
38
- !(actor.kind === "user" ? actor.user?.id : actor.kind === "service_account" ? actor.serviceAccount?.id : undefined) ||
39
- !(accessSubject.type === "user" ? accessSubject.userId : accessSubject.serviceAccountId)
40
- )
41
- throw new GeoError("GEO_FORBIDDEN");
42
- return { actor, accessSubject };
43
- };
44
- const geoCallIdentity = (context: GeoCallContext): { actor: RequestActor | AppPwaActor; accessSubject: AccessSubject } => {
45
- if (context.actor?.kind === "device" || context.actor?.kind === "visit") {
46
- if (!context.actor.ownerUserId) throw new GeoError("GEO_FORBIDDEN");
47
- return { actor: context.actor, accessSubject: { type: "user", userId: context.actor.ownerUserId } };
48
- }
49
- return geoIdentity({ actor: context.actor, accessSubject: "accessSubject" in context ? context.accessSubject : undefined });
50
- };
27
+ export { type AppPwaGeoContext, type GeoContext, geoIdentity } from "./identity";
28
+
29
+ import { type AppPwaGeoContext, type GeoCallContext, type GeoContext, geoCallIdentity, geoIdentity } from "./identity";
30
+
51
31
  const bound = (name: string, value: number, min: number, max: number, integer = false) => {
52
32
  if (!Number.isFinite(value) || value > max || value < min || (integer && !Number.isInteger(value)))
53
33
  limitExceeded(name, value < min ? min : max, value);
@@ -1,4 +1,3 @@
1
- import { sql } from "bun";
2
1
  import { HTTPException } from "hono/http-exception";
3
2
  import {
4
3
  DBIP_ATTRIBUTION,
@@ -8,6 +7,7 @@ import {
8
7
  type GeoIpAdminStatus,
9
8
  type GeoIpKind,
10
9
  } from "../../contracts/geoip";
10
+ import { sql } from "../../runtime/clients";
11
11
  import { createGeoIp, type GeoIp, type GeoIpDatabaseInfo, getGeoIp } from "../../server/request-context/geoip";
12
12
  import { audit } from "../audit";
13
13
  import { GeoIpError, parseGeoIpUpload, readGeoIpUpload } from "./upload";
@@ -79,11 +79,11 @@ const serialized = <T>(write: () => Promise<T>): Promise<T> => {
79
79
  return run;
80
80
  };
81
81
  export const createGeoIpService = (options: { db?: typeof sql; reader?: GeoIp; bundled?: () => ReturnType<GeoIp["status"]> } = {}) => {
82
- const db = options.db ?? sql;
82
+ const db = () => options.db ?? sql;
83
83
  const reader = () => options.reader ?? getGeoIp();
84
84
  const status = async (actor: Actor, config: { now?: () => number } = {}): Promise<GeoIpAdminStatus> => {
85
85
  requireAdmin(actor);
86
- const rows = await db<GeoIpUploadInfo[]>`
86
+ const rows = await db()<GeoIpUploadInfo[]>`
87
87
  SELECT d.kind, d.sha256, (d.metadata->>'size')::int AS size, d.database_type, d.build_epoch,
88
88
  d.metadata->'description'->>'en' AS description, d.uploaded_at, d.uploaded_by,
89
89
  COALESCE(NULLIF(u.display_name, ''), u.uid) AS uploader_name
@@ -103,7 +103,7 @@ export const createGeoIpService = (options: { db?: typeof sql; reader?: GeoIp; b
103
103
  database.info.sha256 = sha256;
104
104
  config.signal?.throwIfAborted();
105
105
  await serialized(async () => {
106
- await db.begin(async (tx) => {
106
+ await db().begin(async (tx) => {
107
107
  await tx`
108
108
  INSERT INTO geoip.databases (kind, data, sha256, database_type, build_epoch, metadata, uploaded_by)
109
109
  VALUES (${kind}, ${bytes}, ${sha256}, ${metadata.databaseType}, ${metadata.buildEpoch}::timestamptz,
@@ -132,7 +132,7 @@ export const createGeoIpService = (options: { db?: typeof sql; reader?: GeoIp; b
132
132
  async reset(actor: Actor, kind: GeoIpKind) {
133
133
  requireAdmin(actor);
134
134
  const changed = await serialized(async () => {
135
- const deleted = await db.begin(async (tx) => {
135
+ const deleted = await db().begin(async (tx) => {
136
136
  const [row] = await tx<{ sha256: string; database_type: string; build_epoch: Date; size: number }[]>`
137
137
  DELETE FROM geoip.databases WHERE kind = ${kind}
138
138
  RETURNING sha256, database_type, build_epoch, octet_length(data) AS size`;
@@ -1,30 +1,33 @@
1
- import { sql } from "bun";
2
1
  import { GEOIP_MAX_DATABASE_BYTES, type GeoIpKind } from "../../contracts/geoip";
2
+ import { sql } from "../../runtime/clients";
3
3
  import type { GeoIpUploads } from "../../server/request-context/geoip";
4
4
 
5
5
  const SLICE_BYTES = 8 * 1024 * 1024;
6
- export const createGeoIpUploads = (db: typeof sql = sql): GeoIpUploads => ({
7
- async versions() {
8
- const rows = await db<{ kind: GeoIpKind; sha256: string }[]>`SELECT kind, sha256 FROM geoip.databases`;
9
- return Object.fromEntries(rows.map((row) => [row.kind, row.sha256]));
10
- },
11
- async read(kind, sha256) {
12
- const [row] = await db<{ size: number }[]>`
6
+ export const createGeoIpUploads = (injectedDb?: typeof sql): GeoIpUploads => {
7
+ const db = () => injectedDb ?? sql;
8
+ return {
9
+ async versions() {
10
+ const rows = await db()<{ kind: GeoIpKind; sha256: string }[]>`SELECT kind, sha256 FROM geoip.databases`;
11
+ return Object.fromEntries(rows.map((row) => [row.kind, row.sha256]));
12
+ },
13
+ async read(kind, sha256) {
14
+ const [row] = await db()<{ size: number }[]>`
13
15
  SELECT octet_length(data) AS size FROM geoip.databases WHERE kind = ${kind} AND sha256 = ${sha256}`;
14
- if (!row) return null;
15
- if (row.size < 1 || row.size > GEOIP_MAX_DATABASE_BYTES) throw new Error("Invalid GeoIP database size");
16
- const bytes = Buffer.allocUnsafe(row.size);
17
- for (let offset = 0; offset < bytes.length; offset += SLICE_BYTES) {
18
- const length = Math.min(SLICE_BYTES, bytes.length - offset);
19
- const [slice] = await db<{ data: Uint8Array }[]>`
16
+ if (!row) return null;
17
+ if (row.size < 1 || row.size > GEOIP_MAX_DATABASE_BYTES) throw new Error("Invalid GeoIP database size");
18
+ const bytes = Buffer.allocUnsafe(row.size);
19
+ for (let offset = 0; offset < bytes.length; offset += SLICE_BYTES) {
20
+ const length = Math.min(SLICE_BYTES, bytes.length - offset);
21
+ const [slice] = await db()<{ data: Uint8Array }[]>`
20
22
  SELECT substring(data FROM ${offset + 1} FOR ${length}) AS data
21
23
  FROM geoip.databases WHERE kind = ${kind} AND sha256 = ${sha256}`;
22
- if (!slice) return null;
23
- if (slice.data.byteLength !== length) throw new Error("Incomplete GeoIP database slice");
24
- bytes.set(slice.data, offset);
25
- }
26
- return bytes;
27
- },
28
- });
24
+ if (!slice) return null;
25
+ if (slice.data.byteLength !== length) throw new Error("Incomplete GeoIP database slice");
26
+ bytes.set(slice.data, offset);
27
+ }
28
+ return bytes;
29
+ },
30
+ };
31
+ };
29
32
 
30
33
  export const geoIpUploads = createGeoIpUploads();
@@ -1,8 +1,9 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import { appAudienceRoles, hasAnyAppRole } from "../../_internal/app-roles";
3
3
  import { getApp, listApps } from "../../_internal/registry";
4
4
  import type { AppRegistryEntry } from "../../contracts/registry";
5
5
  import type { RequestActor } from "../../contracts/shared";
6
+ import { sql } from "../../runtime/clients";
6
7
  import type { RequestAuthority } from "../../server/middleware/auth";
7
8
  import { resolveAppPresentations } from "../../shared/app-presentation";
8
9
  import { type HelpArticleLink, helpLocaleChain, parseHelpReference } from "../../shared/help";
@@ -1,5 +1,6 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
3
+ import { sql } from "../../runtime/clients";
3
4
  import type { HelpAccessLevel, HelpKind } from "../../shared/help";
4
5
  import { ensureSchema, toPgTextArray } from "../postgres";
5
6
  import type { HelpCorpus, HelpMetadata } from "./types";