@k2b/cloud 0.43.0 → 0.44.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 (267) hide show
  1. package/package.json +7 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -2,6 +2,7 @@ import type { Context, Env, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
3
  import { matchedRoutes } from "hono/route";
4
4
  import { findTargetHandler } from "hono/utils/handler";
5
+ import { bindAppAccessRequest } from "../../_internal/app-access-request";
5
6
  import { measureServerPhase } from "../../_internal/server-timing";
6
7
  import {
7
8
  AI_INFERENCE_SCOPE,
@@ -58,6 +59,8 @@ export type ServiceAccountRequestActor =
58
59
  delegatedUser: User;
59
60
  scopes: string[];
60
61
  credentialId?: string | null;
62
+ /** Public OAuth client identifier; safe to retain, never the bearer token. */
63
+ oauthClientId?: string | null;
61
64
  credentialExpiresAt?: string | null;
62
65
  delegation?: InvocationProvenance;
63
66
  }
@@ -67,6 +70,8 @@ export type ServiceAccountRequestActor =
67
70
  delegatedUser: null;
68
71
  scopes: string[];
69
72
  credentialId?: string | null;
73
+ /** Public OAuth client identifier; safe to retain, never the bearer token. */
74
+ oauthClientId?: string | null;
70
75
  credentialExpiresAt?: string | null;
71
76
  delegation?: InvocationProvenance;
72
77
  };
@@ -248,6 +253,7 @@ const loadAuthenticatedActorUncached = async (
248
253
  delegatedUser: authResult.delegatedUser,
249
254
  scopes: authResult.scopes,
250
255
  credentialId: null,
256
+ oauthClientId: authResult.oauthClientId,
251
257
  credentialExpiresAt: typeof authResult.payload.exp === "number" ? new Date(authResult.payload.exp * 1_000).toISOString() : null,
252
258
  };
253
259
  c.set("actor", actor);
@@ -482,13 +488,21 @@ const getAuthority = <E extends AuthContext>(c: Context<E>): RequestAuthority =>
482
488
  const accessSubject = c.get("accessSubject");
483
489
  const credentialKind = c.get("credentialKind");
484
490
  if (!actor || !accessSubject || !credentialKind) throw new Error("Request authority has not been resolved");
485
- return {
486
- actor,
487
- accessSubject,
488
- credentialKind,
489
- scopes: [...(c.get("credentialScopes") ?? [])],
490
- ...(c.get("credentialProof") ? { credentialProof: c.get("credentialProof") } : {}),
491
- };
491
+ return bindAppAccessRequest(
492
+ {
493
+ actor,
494
+ accessSubject,
495
+ credentialKind,
496
+ scopes: [...(c.get("credentialScopes") ?? [])],
497
+ ...(c.get("credentialProof") ? { credentialProof: c.get("credentialProof") } : {}),
498
+ },
499
+ c.req.raw,
500
+ credentialKind === "session"
501
+ ? c.get("sessionToken")
502
+ : credentialKind !== "invocation"
503
+ ? (session.getBearerToken(c) ?? undefined)
504
+ : undefined,
505
+ );
492
506
  };
493
507
 
494
508
  /**
@@ -4,6 +4,7 @@ export {
4
4
  type AuthContext,
5
5
  auth,
6
6
  type InvocationProvenance,
7
+ isRequestCredentialCurrent,
7
8
  type RequestActor,
8
9
  type RequestAuthority,
9
10
  type RequestCredentialKind,
@@ -1,5 +1,6 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
+ import { bindAppAccessRequest } from "../../_internal/app-access-request";
3
4
  import { getApp } from "../../_internal/registry";
4
5
  import type { AppMeta } from "../../contracts/app";
5
6
  import { canUseApp } from "../../services/app-access";
@@ -53,6 +54,7 @@ export const requireInvocation = (
53
54
  if (!claims) return reject(c);
54
55
  const authority = await (dependencies.resolve ?? resolveInvocationAuthority)(claims);
55
56
  if (!authority) return reject(c);
57
+ bindAppAccessRequest(authority, c.req.raw);
56
58
  const app = await (dependencies.getApp ?? getApp)(expectation.targetAppId).catch(() => null);
57
59
  if (!app || !(await canUseApp(authority, app))) {
58
60
  if (app) recordAppAccessDenied(app.id);
@@ -0,0 +1,106 @@
1
+ import { objectStoreBackend } from "../_internal/object-store-config";
2
+ import { validateObjectKey } from "../_internal/object-store-key";
3
+ import { get } from "../services/settings";
4
+ import { clientIpBucket } from "./client-ip";
5
+ import { ratelimit } from "./ratelimit";
6
+
7
+ const notFound = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
8
+
9
+ /** Platform-owned anonymous delivery. Never accepts a bucket, private prefix or listing. */
10
+ export const servePublicStorage = async (request: Request, clientAddress = "unknown"): Promise<Response> => {
11
+ let path: string;
12
+ try {
13
+ // Validate before application decoding; Bun has already resolved transport
14
+ // dot segments. The S3 key below always starts in the public area.
15
+ const raw = request.url.replace(/^[a-z]+:\/\/[^/]+/i, "").split(/[?#]/, 1)[0] ?? "";
16
+ if (/[\\]|%(?:2e|2f|5c)/i.test(raw)) return notFound();
17
+ const match = /^\/public\/storage\/([a-z][a-z0-9-]{0,79})\/(.+)$/.exec(raw);
18
+ if (!match || match[1] === "storage") return notFound();
19
+ const area = `public/${match[1]}/`;
20
+ path = area + validateObjectKey(decodeURIComponent(match[2] ?? ""), area);
21
+ } catch {
22
+ return notFound();
23
+ }
24
+ if (request.method !== "GET" && request.method !== "HEAD") return notFound();
25
+ try {
26
+ const perSecond = Math.max(1, Math.floor(await get<number>("security.rate_limit_per_second")));
27
+ const budget = await ratelimit({ id: "public-storage", limit: perSecond }).check(clientIpBucket(clientAddress));
28
+ if (budget.limited)
29
+ return new Response("Rate limit exceeded.", {
30
+ status: 429,
31
+ headers: { "Cache-Control": "no-store", "Retry-After": String(Math.max(1, Math.ceil(budget.resetIn / 1000))) },
32
+ });
33
+ const { client, limit } = await objectStoreBackend();
34
+ const stat = await client.stat(path);
35
+ if (stat.size > limit) return notFound();
36
+ const headers = new Headers({
37
+ "Content-Type": stat.type || "application/octet-stream",
38
+ "Content-Length": String(stat.size),
39
+ ETag: stat.etag,
40
+ "Last-Modified": stat.lastModified.toUTCString(),
41
+ "Cache-Control": "public, max-age=300",
42
+ "X-Content-Type-Options": "nosniff",
43
+ // Apply to every type, including mislabelled or future active formats.
44
+ "Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
45
+ });
46
+ const essence = stat.type.split(";", 1)[0]?.trim().toLowerCase() ?? "";
47
+ const inline =
48
+ /^(?:image\/(?:png|jpeg|gif|webp|avif|bmp|x-icon)|audio\/(?:mpeg|ogg|wav|webm|mp4)|video\/(?:mp4|webm|ogg)|text\/plain)$/;
49
+ if (!inline.test(essence)) headers.set("Content-Disposition", "attachment");
50
+ const ifNoneMatch = request.headers.get("if-none-match");
51
+ const modifiedSince = request.headers.get("if-modified-since");
52
+ if (
53
+ ifNoneMatch
54
+ ? ifNoneMatch.split(",").some((value) => value.trim() === stat.etag || value.trim() === "*")
55
+ : modifiedSince && stat.lastModified.getTime() <= Date.parse(modifiedSince)
56
+ ) {
57
+ headers.delete("Content-Length");
58
+ return new Response(null, { status: 304, headers });
59
+ }
60
+ if (request.method === "HEAD") return new Response(null, { headers });
61
+ const reader = client.file(path).stream().getReader();
62
+ let size = 0;
63
+ const abort = () => {
64
+ void reader.cancel(request.signal.reason).catch(() => {});
65
+ };
66
+ request.signal.addEventListener("abort", abort, { once: true });
67
+ const release = () => {
68
+ request.signal.removeEventListener("abort", abort);
69
+ reader.releaseLock();
70
+ };
71
+ if (request.signal.aborted) abort();
72
+ const stream = new ReadableStream<Uint8Array>(
73
+ {
74
+ async pull(controller) {
75
+ try {
76
+ const next = await reader.read();
77
+ if (next.done) {
78
+ release();
79
+ controller.close();
80
+ return;
81
+ }
82
+ size += next.value.byteLength;
83
+ if (size > limit) throw new RangeError("Public object exceeds the configured size limit.");
84
+ controller.enqueue(next.value);
85
+ } catch (error) {
86
+ await reader.cancel(error).catch(() => {});
87
+ release();
88
+ controller.error(error);
89
+ }
90
+ },
91
+ async cancel(reason) {
92
+ try {
93
+ await reader.cancel(reason);
94
+ } finally {
95
+ release();
96
+ }
97
+ },
98
+ },
99
+ { highWaterMark: 0 },
100
+ );
101
+ return new Response(stream, { headers });
102
+ } catch (error) {
103
+ if (error instanceof Error && "code" in error && ["NoSuchKey", "NotFound", "ENOENT"].includes(String(error.code))) return notFound();
104
+ return new Response("Storage is unavailable.", { status: 503, headers: { "Cache-Control": "no-store" } });
105
+ }
106
+ };
@@ -5,6 +5,7 @@ import { accessMessages } from "../../access/messages";
5
5
  import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
6
6
  import { toPgUuidArray } from "../../services/postgres";
7
7
  import type { ServiceAccountKind } from "../../services/service-accounts";
8
+ import type { RequestAuthority } from "../middleware/auth";
8
9
 
9
10
  export type AccessDb = typeof sql;
10
11
 
@@ -305,6 +306,13 @@ export const deleteAccess = async (params: { id: string }, db: AccessDb = sql):
305
306
  * Each app implements this to connect their resources to auth.access.
306
307
  */
307
308
  export type ResourceAccessAdapter<TResourceId = string> = {
309
+ /**
310
+ * Whether any resource in this adapter has an effective read/write/admin right.
311
+ * Use an existence query, never enumerate resources. Preserve domain eligibility,
312
+ * principal semantics, resource binding and credential ceilings. Use db and signal.
313
+ * Optional for existing adapters; required when selected by appAccess.resources().
314
+ */
315
+ hasAnyRight?: (subject: AccessSubject, context: { authority: RequestAuthority; db: AccessDb; signal: AbortSignal }) => Promise<boolean>;
308
316
  /** Get all access entries for a resource */
309
317
  list: (resourceId: TResourceId) => Promise<AccessEntry[]>;
310
318
  /** Add an access entry to a resource */
@@ -8,6 +8,8 @@ import type {
8
8
  EntityListItem,
9
9
  GroupMember,
10
10
  MutationResult,
11
+ ServiceAccountKind,
12
+ ServiceAccountStatus,
11
13
  User,
12
14
  UserProfile,
13
15
  UserProvider,
@@ -1253,6 +1255,8 @@ export const accountsAppService = {
1253
1255
  profile?: UserProfile;
1254
1256
  userIds?: string[];
1255
1257
  groupIds?: string[];
1258
+ serviceAccountKinds?: ServiceAccountKind[];
1259
+ serviceAccountStatus?: ServiceAccountStatus;
1256
1260
  excludeUserIds?: string[];
1257
1261
  excludeGroupIds?: string[];
1258
1262
  excludeServiceAccountIds?: string[];
@@ -1284,6 +1288,8 @@ export const accountsAppService = {
1284
1288
  profile: config.profile,
1285
1289
  userIds: config.userIds,
1286
1290
  groupIds: config.groupIds,
1291
+ serviceAccountKinds: config.serviceAccountKinds,
1292
+ serviceAccountStatus: config.serviceAccountStatus,
1287
1293
  excludeUserIds: config.excludeUserIds,
1288
1294
  excludeGroupIds: config.excludeGroupIds,
1289
1295
  excludeServiceAccountIds: config.excludeServiceAccountIds,
@@ -2,7 +2,7 @@ import { sql } from "bun";
2
2
  import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
3
3
  import { getFreeIpaConfig } from "../freeipa-config";
4
4
  import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
5
- import type { ServiceAccountKind } from "../service-accounts";
5
+ import type { ServiceAccountKind, ServiceAccountStatus } from "../service-accounts";
6
6
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
7
7
  import { buildBaseUser } from "./base-user";
8
8
  import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
@@ -21,6 +21,8 @@ export type EntityListParams = {
21
21
  profile?: UserProfile;
22
22
  userIds?: string[];
23
23
  groupIds?: string[];
24
+ serviceAccountKinds?: ServiceAccountKind[];
25
+ serviceAccountStatus?: ServiceAccountStatus;
24
26
  excludeUserIds?: string[];
25
27
  excludeGroupIds?: string[];
26
28
  excludeServiceAccountIds?: string[];
@@ -472,6 +474,13 @@ export const list = async (
472
474
  (params.excludeServiceAccountIds?.length ?? 0) === 0
473
475
  ? sql`TRUE`
474
476
  : sql`(kind <> 'service_account' OR id <> ALL(${toPgUuidArray(params.excludeServiceAccountIds ?? [])}::uuid[]))`;
477
+ const serviceAccountKindsCondition =
478
+ (params.serviceAccountKinds?.length ?? 0) === 0
479
+ ? sql`TRUE`
480
+ : sql`(kind <> 'service_account' OR service_account_kind = ANY(${toPgTextArray(params.serviceAccountKinds ?? [])}::text[]))`;
481
+ const serviceAccountStatusCondition = params.serviceAccountStatus
482
+ ? sql`(kind <> 'service_account' OR status = ${params.serviceAccountStatus})`
483
+ : sql`TRUE`;
475
484
  const userMemberOfGroupCondition =
476
485
  (params.userMemberOfGroupIds?.length ?? 0) === 0
477
486
  ? sql`TRUE`
@@ -491,6 +500,8 @@ export const list = async (
491
500
  AND ${excludeUserCondition}
492
501
  AND ${excludeGroupCondition}
493
502
  AND ${excludeServiceAccountCondition}
503
+ AND ${serviceAccountKindsCondition}
504
+ AND ${serviceAccountStatusCondition}
494
505
  AND ${userMemberOfGroupCondition}
495
506
  AND ${personalCondition}
496
507
  AND (
@@ -649,9 +660,9 @@ export const list = async (
649
660
  OFFSET ${offset}
650
661
  `;
651
662
 
663
+ // A page past the end has no rows to carry the window count; the total still counts every match.
652
664
  let total = rows.length > 0 ? Number(rows[0]!.total) : 0;
653
- if (params.visibility.type === "directory_shares" && !rows.length && page > 1)
654
- total = (await list({ ...params, page: 1, perPage: 1 })).total;
665
+ if (!rows.length && page > 1) total = (await list({ ...params, page: 1, perPage: 1 })).total;
655
666
  return {
656
667
  items: rows.map((row) => {
657
668
  const entity = mapEntityRow(row);
@@ -86,7 +86,9 @@ const listCanonical = async (params: {
86
86
  OFFSET ${offset}
87
87
  `;
88
88
 
89
- const total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
89
+ // A page past the end has no rows to carry the window count; the total still counts every match.
90
+ let total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
91
+ if (!rows.length && page > 1) total = (await listCanonical({ ...params, page: 1, perPage: 1 })).total;
90
92
  return {
91
93
  groups: rows.map(buildBaseGroup),
92
94
  total,
@@ -207,22 +207,74 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
207
207
  if ((input.kind !== "users" && input.kind !== "groups") || typeof input.name !== "string" || !input.name || input.name.includes("\0"))
208
208
  throw new AccountIdentityError("invalid_identity_lookup", 400);
209
209
  };
210
+ const reconcileIdentity = async (
211
+ input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
212
+ state: AccountIdentityAvailability,
213
+ ): Promise<AccountIdentityReconciliation> => {
214
+ validateLookup(input);
215
+ if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
216
+ if (input.identityId !== undefined) cursor(input.identityId);
217
+ if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
218
+ if (input.provider === "ipa") {
219
+ if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
220
+ return upstreamIdentity(input);
221
+ }
222
+ const result = await readLocalIdentity(input);
223
+ return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
224
+ };
210
225
  return {
226
+ /** Trusted application provisioning only; no profile, grants, credentials, or administrator impersonation. */
227
+ async provisioningUsers(input: { provider?: UserProvider; id?: string; after?: string }) {
228
+ if (input.provider !== undefined && input.provider !== "local" && input.provider !== "ipa")
229
+ throw new AccountIdentityError("invalid_provider", 400);
230
+ const id = cursor(input.id);
231
+ const after = cursor(input.after);
232
+ const state = await availability();
233
+ const [localAllowed, ipaAllowed] = await Promise.all([
234
+ isAccountCategoryAllowed({ provider: "local", profile: "user" }, db),
235
+ isAccountCategoryAllowed({ provider: "ipa", profile: "user" }, db),
236
+ ]);
237
+ const rows = await db<
238
+ {
239
+ id: string;
240
+ provider: UserProvider;
241
+ uid: string;
242
+ managed_by: UserProvider | null;
243
+ uid_number: number | null;
244
+ primary_gid_number: number | null;
245
+ }[]
246
+ >`SELECT u.id,u.provider,u.uid,p.managed_by,p.uid_number,p.primary_gid_number
247
+ FROM auth.users u LEFT JOIN auth.user_posix p ON p.user_id=u.id
248
+ WHERE u.provider IN ('local','ipa') AND (${input.provider ?? null}::text IS NULL OR u.provider=${input.provider ?? null})
249
+ AND u.profile='user'
250
+ AND (u.account_expires IS NULL OR u.account_expires>now())
251
+ AND (${id}::uuid IS NULL OR u.id=${id}::uuid)
252
+ AND (${after}::uuid IS NULL OR u.id>${after}::uuid)
253
+ ORDER BY u.id LIMIT ${PAGE_SIZE + 1}`;
254
+ const source = page(rows);
255
+ const items = source.items
256
+ .filter((row) => (row.provider === "local" ? localAllowed : ipaAllowed && state.freeipaEnabled))
257
+ .map((row) => ({
258
+ id: row.id,
259
+ provider: row.provider,
260
+ username: row.uid,
261
+ posix:
262
+ row.managed_by === row.provider && positiveId(row.uid_number) && positiveId(row.primary_gid_number)
263
+ ? { uidNumber: row.uid_number, primaryGidNumber: row.primary_gid_number }
264
+ : null,
265
+ }));
266
+ return { items, nextCursor: source.nextCursor, availability: state };
267
+ },
211
268
  async reconcile(
212
269
  actor: RequestActor,
213
270
  input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
214
271
  ): Promise<AccountIdentityReconciliation> {
215
272
  const { state } = await requireAdmin(actor);
216
- validateLookup(input);
217
- if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
218
- if (input.identityId !== undefined) cursor(input.identityId);
219
- if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
220
- if (input.provider === "ipa") {
221
- if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
222
- return upstreamIdentity(input);
223
- }
224
- const result = await readLocalIdentity(input);
225
- return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
273
+ return reconcileIdentity(input, state);
274
+ },
275
+ /** Trusted provisioning services use the same live identity check as administrator provisioning. */
276
+ async provisioningIdentity(input: { kind: "users"; provider: UserProvider; name: string; identityId: string; signal?: AbortSignal }) {
277
+ return reconcileIdentity(input, await availability());
226
278
  },
227
279
  /** Trusted application jobs only; this read does not authorize an HTTP caller or a filesystem mutation. */
228
280
  async localLifecycle(input: { kind: "users" | "groups"; identityId: string; name: string }) {
@@ -4,7 +4,7 @@ import { freeipa } from "../../server/services";
4
4
  import { createAuthLoginUrl } from "../../shared/redirect";
5
5
  import { isAccountCategoryAllowed } from "../account-category-policy";
6
6
  import type { AuditActor } from "../audit";
7
- import { getFreeIpaConfig } from "../freeipa-config";
7
+ import { getFreeIpaAdminGroups, getFreeIpaConfig } from "../freeipa-config";
8
8
  import { getServiceIpaSession } from "../ipa/service-account";
9
9
  import { toPgTextArray, toPgUuidArray } from "../postgres";
10
10
  import { providers } from "../providers";
@@ -168,11 +168,12 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
168
168
  };
169
169
  };
170
170
 
171
- export const get = async (params: { id: string } | { uid: string }): Promise<User | null> => {
172
- const whereClause = "id" in params ? sql`u.id = ${params.id}` : sql`u.uid = ${params.uid}`;
173
- const userIdExpr = "id" in params ? sql`${params.id}` : sql`u.id`;
174
- const { groupsAdmin } = await getFreeIpaConfig();
175
- const rows = await sql<DbRow[]>`
171
+ export const get = async (params: { id: string } | { uid: string }, options: { db?: SQL } = {}): Promise<User | null> => {
172
+ const db = options.db ?? sql;
173
+ const whereClause = "id" in params ? db`u.id = ${params.id}` : db`u.uid = ${params.uid}`;
174
+ const userIdExpr = "id" in params ? db`${params.id}` : db`u.id`;
175
+ const groupsAdmin = options.db ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
176
+ const rows = await db<DbRow[]>`
176
177
  SELECT u.*,
177
178
  ${userIpaDataColumns()},
178
179
  CASE
@@ -1,9 +1,10 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import type { Context } from "hono";
3
+ import { RESOURCE_ACCESS_TIMEOUT_MS, resourceAppAccess } from "../_internal/app-access-resources";
3
4
  import type { AppMeta } from "../contracts/app";
4
5
  import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
5
6
  import { userFromActor } from "../server/actor";
6
- import type { AuthContext, RequestAuthority } from "../server/middleware/auth";
7
+ import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
7
8
  import { buildAccessPrincipalCondition } from "../server/services/access";
8
9
  import { logger } from "./logging";
9
10
  import { toPgTextArray } from "./postgres";
@@ -17,7 +18,7 @@ export const appAccessAuthority = <E extends AuthContext>(c: Context<E>): Reques
17
18
  const accessSubject = c.get("accessSubject");
18
19
  const credentialKind = c.get("credentialKind");
19
20
  if (!actor || !accessSubject || !credentialKind) return null;
20
- return { actor, accessSubject, credentialKind, scopes: c.get("credentialScopes") ?? [] };
21
+ return auth.getAuthority(c);
21
22
  };
22
23
 
23
24
  const credentialAllows = (authority: RequestAuthority, appId: string): boolean => {
@@ -65,6 +66,7 @@ export const evaluateAppAccess = (authority: RequestAuthority | null, app: Acces
65
66
  const parsed = AppAccessRuleSchema.safeParse(app.access);
66
67
  if (!parsed.success) return false;
67
68
  const rule = parsed.data;
69
+ if (rule.kind === "resources") return false; // Asynchronous adapter decision only.
68
70
  if (rule.kind !== "grants") return fixedAllows(authority, rule);
69
71
  if (!state?.configured) return !!rule.until && fixedAllows(authority, rule.until);
70
72
  return state.permission === "read" || state.permission === "admin";
@@ -74,21 +76,34 @@ let lastFailureLog = -Infinity;
74
76
  const reportFailure = () => {
75
77
  if (Date.now() - lastFailureLog < 60_000) return;
76
78
  lastFailureLog = Date.now();
77
- logger("app-access").error("Application access could not be resolved; configurable applications are unavailable");
79
+ logger("app-access").error("Application access could not be resolved; affected applications are unavailable");
78
80
  };
79
81
 
80
82
  /**
81
- * One authoritative SQL read for all configurable apps. No grant cache: revocation applies on the next request.
83
+ * One authoritative SQL read for all configurable lists; resource rules query the owning adapters.
84
+ * No cross-request grant cache: revocation applies on the next request.
82
85
  * Pass the reserved transaction as `db` when checking access inside a guarded operation.
86
+ * A supplied `signal` replaces the default resource budget; the caller must bound it.
83
87
  */
84
88
  export const usableAppIds = async (
85
89
  authority: RequestAuthority | null,
86
90
  apps: readonly AccessApp[],
87
- options: { db?: SQL } = {},
91
+ options: { db?: SQL; signal?: AbortSignal } = {},
88
92
  ): Promise<Set<string>> => {
89
93
  const db = options.db ?? sql;
90
94
  const allowed = new Set(apps.filter((app) => app.access?.kind !== "grants" && evaluateAppAccess(authority, app)).map((app) => app.id));
91
95
  if (!authority) return allowed;
96
+ let resourceSignal = options.signal;
97
+ // Sequential: one existence probe in flight per decision, no resource enumeration.
98
+ for (const app of apps) {
99
+ if (app.access?.kind !== "resources" || !credentialAllows(authority, app.id)) continue;
100
+ resourceSignal ??= AbortSignal.timeout(RESOURCE_ACCESS_TIMEOUT_MS);
101
+ try {
102
+ if (await resourceAppAccess(authority, app, { ...options, signal: resourceSignal })) allowed.add(app.id);
103
+ } catch {
104
+ if (!options.signal?.aborted) reportFailure();
105
+ }
106
+ }
92
107
  const configurable = apps.filter((app) => app.access?.kind === "grants" && credentialAllows(authority, app.id));
93
108
  if (configurable.length === 0) return allowed;
94
109
  try {
@@ -119,5 +134,8 @@ export const usableAppIds = async (
119
134
  return allowed;
120
135
  };
121
136
 
122
- export const canUseApp = async (authority: RequestAuthority | null, app: AccessApp, options: { db?: SQL } = {}): Promise<boolean> =>
123
- (await usableAppIds(authority, [app], options)).has(app.id);
137
+ export const canUseApp = async (
138
+ authority: RequestAuthority | null,
139
+ app: AccessApp,
140
+ options: { db?: SQL; signal?: AbortSignal } = {},
141
+ ): Promise<boolean> => (await usableAppIds(authority, [app], options)).has(app.id);