@opengeni/core 3.0.1 → 3.1.2

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 (257) hide show
  1. package/README.md +4 -4
  2. package/dist/access/index.d.ts +131 -7
  3. package/dist/access/service-initiator.d.ts +3 -0
  4. package/dist/application/archived-session-imports.d.ts +6 -0
  5. package/dist/application/composer-submit.d.ts +1 -1
  6. package/dist/application/connect-operation.d.ts +6 -0
  7. package/dist/application/external-workspace-members.d.ts +13 -1
  8. package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
  9. package/dist/application/modal-native-original-configuration.d.ts +79 -0
  10. package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
  11. package/dist/application/new-session-drafts.d.ts +29 -2
  12. package/dist/application/organization-privacy.d.ts +57 -0
  13. package/dist/application/prepared-mcp-connection.d.ts +31 -0
  14. package/dist/application/sandbox-recovery.d.ts +58 -0
  15. package/dist/application/session-commands.d.ts +3 -0
  16. package/dist/application/session-tenancy.d.ts +13 -4
  17. package/dist/billing/agent-run-admission.d.ts +21 -0
  18. package/dist/billing/limits.d.ts +27 -1
  19. package/dist/billing/model-call-admission.d.ts +34 -0
  20. package/dist/billing/model-usage-settlement.d.ts +112 -0
  21. package/dist/canonical-human-identities.js +1 -1
  22. package/dist/{chunk-VCZFFEPB.js → chunk-7T2SZ7BI.js} +16 -2
  23. package/dist/chunk-7T2SZ7BI.js.map +1 -0
  24. package/dist/{chunk-ZYCNLYGH.js → chunk-OMJFIIWK.js} +178 -12
  25. package/dist/chunk-OMJFIIWK.js.map +1 -0
  26. package/dist/{chunk-H7X52RI2.js → chunk-QI2M2B2K.js} +83 -8
  27. package/dist/chunk-QI2M2B2K.js.map +1 -0
  28. package/dist/{chunk-OF65T3PM.js → chunk-TTWTXQB2.js} +94 -6
  29. package/dist/chunk-TTWTXQB2.js.map +1 -0
  30. package/dist/codex-model-availability.d.ts +55 -0
  31. package/dist/default-session-model.d.ts +192 -0
  32. package/dist/dependencies.d.ts +41 -7
  33. package/dist/developer-setup-credential-ceiling.d.ts +10 -0
  34. package/dist/domain/agent-config-resolution.d.ts +49 -0
  35. package/dist/domain/automations.d.ts +8 -1
  36. package/dist/domain/capabilities.d.ts +112 -8
  37. package/dist/domain/connection-account-selection-error.d.ts +8 -0
  38. package/dist/domain/connector-tool-permissions.d.ts +62 -0
  39. package/dist/domain/direct-model-provider.d.ts +3 -0
  40. package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
  41. package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
  42. package/dist/domain/editable-artifacts/ports.d.ts +10 -0
  43. package/dist/domain/editable-artifacts/service.d.ts +3 -1
  44. package/dist/domain/editable-artifacts/types.d.ts +1 -1
  45. package/dist/domain/encryption-key.d.ts +12 -0
  46. package/dist/domain/insights-timing.d.ts +16 -0
  47. package/dist/domain/insights-usage.d.ts +27 -0
  48. package/dist/domain/insights.d.ts +21 -3
  49. package/dist/domain/knowledge-files.d.ts +1 -1
  50. package/dist/domain/knowledge-preparation.d.ts +10 -0
  51. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  52. package/dist/domain/knowledge-search.d.ts +15 -76
  53. package/dist/domain/mcp-account-bindings.d.ts +17 -0
  54. package/dist/domain/mcp-account-routes.d.ts +21 -0
  55. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  56. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  57. package/dist/domain/personal-connection-delegations.d.ts +73 -12
  58. package/dist/domain/pr-review.d.ts +121 -6
  59. package/dist/domain/realtime-voice-billing.d.ts +103 -0
  60. package/dist/domain/resources.d.ts +17 -1
  61. package/dist/domain/scheduled-task-access.d.ts +265 -0
  62. package/dist/domain/scheduled-task-account-audit.d.ts +27 -0
  63. package/dist/domain/scheduled-task-audience.d.ts +70 -0
  64. package/dist/domain/scheduled-tasks.d.ts +61 -18
  65. package/dist/domain/session-connection-accounts.d.ts +53 -0
  66. package/dist/domain/session-file-access.d.ts +4 -0
  67. package/dist/domain/session-native-connection-replacement.d.ts +14 -0
  68. package/dist/domain/session-start-timing.d.ts +7 -0
  69. package/dist/domain/session-tool-policy.d.ts +102 -4
  70. package/dist/domain/sessions.d.ts +179 -15
  71. package/dist/domain/site-audience.d.ts +52 -0
  72. package/dist/domain/skills.d.ts +29 -1
  73. package/dist/domain/slack-bot.d.ts +46 -1
  74. package/dist/domain/slack-publication-secret-safety.d.ts +1 -1
  75. package/dist/domain/vaults/index.d.ts +7 -0
  76. package/dist/domain/vaults/mapping.d.ts +25 -0
  77. package/dist/domain/vaults/provider-registry.d.ts +72 -0
  78. package/dist/domain/vaults/provider-sync.d.ts +176 -0
  79. package/dist/domain/vaults/provider-writes.d.ts +57 -0
  80. package/dist/domain/vaults/secret-resolver.d.ts +34 -0
  81. package/dist/domain/vaults/types.d.ts +24 -0
  82. package/dist/domain/vaults/vault-service.d.ts +35 -0
  83. package/dist/domain/voice-input-billing.d.ts +77 -0
  84. package/dist/domain/web-search-billing.d.ts +51 -0
  85. package/dist/domain/workspace-credits.d.ts +7 -0
  86. package/dist/editable-artifact-live/application.d.ts +2 -1
  87. package/dist/editable-artifact-live/server.d.ts +5 -1
  88. package/dist/editable-artifact-live/ticket.d.ts +2 -1
  89. package/dist/editable-artifact-live/types.d.ts +9 -0
  90. package/dist/editable-artifact-live.js +2 -2
  91. package/dist/editable-artifacts.js +10 -2
  92. package/dist/goal-admission.d.ts +627 -0
  93. package/dist/host-session-usage-limit.d.ts +15 -0
  94. package/dist/index.d.ts +43 -8
  95. package/dist/index.js +22682 -11743
  96. package/dist/index.js.map +1 -1
  97. package/dist/managed-session.d.ts +110 -7
  98. package/dist/model-call.d.ts +58 -0
  99. package/dist/model-catalog.d.ts +29 -3
  100. package/dist/model-provider-credentials.d.ts +43 -0
  101. package/dist/product-usage-metrics.d.ts +30 -0
  102. package/dist/rigs/index.d.ts +1 -13
  103. package/dist/sandbox/fleet.d.ts +58 -4
  104. package/dist/sandbox/routing.d.ts +42 -2
  105. package/dist/sandbox/runtime-settings.d.ts +27 -17
  106. package/dist/session-authorization.d.ts +10 -0
  107. package/dist/session-usage-limit-metrics.d.ts +60 -0
  108. package/dist/session-usage-limits.d.ts +89 -0
  109. package/dist/site-agent-clamp.d.ts +71 -0
  110. package/dist/site-session-origin.d.ts +39 -2
  111. package/dist/transcription.d.ts +120 -3
  112. package/dist/turn-surface.d.ts +20 -0
  113. package/dist/user-presence.d.ts +32 -0
  114. package/dist/workflow-wake-contract.d.ts +13 -1
  115. package/package.json +15 -22
  116. package/src/access/index.ts +961 -38
  117. package/src/access/service-initiator.ts +50 -0
  118. package/src/application/api-integration-servers.ts +9 -0
  119. package/src/application/archived-session-imports.ts +161 -0
  120. package/src/application/composer-submit.ts +3 -2
  121. package/src/application/connect-authority.ts +3 -3
  122. package/src/application/connect-operation.ts +37 -3
  123. package/src/application/external-continuation.ts +20 -3
  124. package/src/application/external-link-work-admission.ts +3 -0
  125. package/src/application/external-workspace-members.ts +51 -3
  126. package/src/application/modal-native-live-declaration-join.ts +196 -0
  127. package/src/application/modal-native-original-configuration.ts +261 -0
  128. package/src/application/modal-native-worker-host-transport.ts +263 -0
  129. package/src/application/new-session-drafts.ts +172 -55
  130. package/src/application/organization-privacy.ts +161 -0
  131. package/src/application/prepared-mcp-connection.ts +133 -0
  132. package/src/application/sandbox-recovery.ts +78 -0
  133. package/src/application/session-commands.ts +73 -1
  134. package/src/application/session-mcp-credential-rotation.ts +61 -18
  135. package/src/application/session-tenancy.ts +262 -44
  136. package/src/application/user-resource-grants.ts +1 -29
  137. package/src/billing/agent-run-admission.ts +117 -0
  138. package/src/billing/limits.ts +138 -14
  139. package/src/billing/model-call-admission.ts +118 -0
  140. package/src/billing/model-usage-settlement.ts +472 -0
  141. package/src/codex-model-availability.ts +264 -0
  142. package/src/default-session-model.ts +692 -0
  143. package/src/dependencies.ts +48 -6
  144. package/src/developer-setup-credential-ceiling.ts +60 -0
  145. package/src/domain/agent-config-resolution.ts +148 -0
  146. package/src/domain/automations.ts +76 -2
  147. package/src/domain/capabilities.ts +830 -181
  148. package/src/domain/connection-account-selection-error.ts +19 -0
  149. package/src/domain/connector-tool-permissions.ts +572 -0
  150. package/src/domain/direct-model-provider.ts +62 -0
  151. package/src/domain/editable-artifacts/agent-application.ts +44 -6
  152. package/src/domain/editable-artifacts/durable-export.ts +100 -5
  153. package/src/domain/editable-artifacts/in-memory.ts +6 -0
  154. package/src/domain/editable-artifacts/ports.ts +11 -0
  155. package/src/domain/editable-artifacts/service.ts +20 -0
  156. package/src/domain/editable-artifacts/types.ts +1 -1
  157. package/src/domain/encryption-key.ts +26 -0
  158. package/src/domain/file-owner.ts +16 -1
  159. package/src/domain/github-action-policies.ts +1 -1
  160. package/src/domain/github-skill-source.ts +3 -1
  161. package/src/domain/insights-timing.ts +95 -0
  162. package/src/domain/insights-usage.ts +67 -0
  163. package/src/domain/insights.ts +167 -43
  164. package/src/domain/knowledge-files.ts +10 -3
  165. package/src/domain/knowledge-messages.ts +1 -0
  166. package/src/domain/knowledge-preparation.test.ts +276 -0
  167. package/src/domain/knowledge-preparation.ts +147 -0
  168. package/src/domain/knowledge-search.test.ts +617 -5
  169. package/src/domain/knowledge-search.ts +240 -23
  170. package/src/domain/mcp-account-bindings.ts +169 -0
  171. package/src/domain/mcp-account-routes.ts +152 -0
  172. package/src/domain/native-mcp-connection-admission.ts +14 -0
  173. package/src/domain/organization-integration-catalog.ts +88 -0
  174. package/src/domain/personal-connection-delegations.ts +399 -257
  175. package/src/domain/pr-review.ts +24 -8
  176. package/src/domain/realtime-voice-billing.ts +425 -0
  177. package/src/domain/resources.ts +50 -4
  178. package/src/domain/scheduled-task-access.ts +1089 -0
  179. package/src/domain/scheduled-task-account-audit.ts +90 -0
  180. package/src/domain/scheduled-task-audience.ts +249 -0
  181. package/src/domain/scheduled-tasks.ts +1313 -374
  182. package/src/domain/session-connection-accounts.ts +239 -0
  183. package/src/domain/session-file-access.ts +47 -0
  184. package/src/domain/session-native-connection-replacement.ts +64 -0
  185. package/src/domain/session-start-timing.ts +60 -0
  186. package/src/domain/session-tool-policy.ts +489 -14
  187. package/src/domain/sessions.ts +2254 -717
  188. package/src/domain/site-audience.ts +130 -0
  189. package/src/domain/skills.ts +9 -0
  190. package/src/domain/slack-bot.ts +141 -4
  191. package/src/domain/slack-publication-secret-safety.ts +1 -1
  192. package/src/domain/vaults/index.ts +19 -0
  193. package/src/domain/vaults/mapping.ts +190 -0
  194. package/src/domain/vaults/provider-registry.ts +201 -0
  195. package/src/domain/vaults/provider-sync.ts +531 -0
  196. package/src/domain/vaults/provider-writes.ts +288 -0
  197. package/src/domain/vaults/secret-resolver.ts +464 -0
  198. package/src/domain/vaults/types.ts +26 -0
  199. package/src/domain/vaults/vault-service.ts +1316 -0
  200. package/src/domain/voice-input-billing.ts +330 -0
  201. package/src/domain/web-search-billing.ts +169 -0
  202. package/src/domain/workspace-credits.ts +13 -0
  203. package/src/editable-artifact-live/application.ts +8 -1
  204. package/src/editable-artifact-live/server.ts +154 -6
  205. package/src/editable-artifact-live/ticket.ts +86 -1
  206. package/src/editable-artifact-live/types.ts +10 -0
  207. package/src/goal-admission.ts +220 -0
  208. package/src/host-session-usage-limit.ts +25 -0
  209. package/src/index.ts +75 -11
  210. package/src/managed-session.ts +137 -6
  211. package/src/model-call.ts +79 -0
  212. package/src/model-catalog.ts +345 -91
  213. package/src/model-provider-credentials.ts +394 -0
  214. package/src/product-usage-metrics.ts +101 -0
  215. package/src/rigs/index.ts +23 -111
  216. package/src/sandbox/fleet.ts +253 -28
  217. package/src/sandbox/routing.ts +204 -43
  218. package/src/sandbox/runtime-settings.ts +159 -85
  219. package/src/session-authorization.ts +172 -23
  220. package/src/session-usage-limit-metrics.ts +95 -0
  221. package/src/session-usage-limits.ts +415 -0
  222. package/src/site-agent-clamp.ts +226 -0
  223. package/src/site-session-origin.ts +84 -5
  224. package/src/transcription.ts +123 -4
  225. package/src/turn-surface.ts +47 -0
  226. package/src/user-presence.ts +134 -0
  227. package/src/workflow-wake-contract.ts +14 -1
  228. package/dist/application/host-mcp-owner.d.ts +0 -6
  229. package/dist/application/host-mcp-resolvers.d.ts +0 -13
  230. package/dist/chunk-5WZYFL45.js +0 -266
  231. package/dist/chunk-5WZYFL45.js.map +0 -1
  232. package/dist/chunk-H7X52RI2.js.map +0 -1
  233. package/dist/chunk-OF65T3PM.js.map +0 -1
  234. package/dist/chunk-VCZFFEPB.js.map +0 -1
  235. package/dist/chunk-ZYCNLYGH.js.map +0 -1
  236. package/dist/domain/connection-authority.d.ts +0 -97
  237. package/dist/domain/connection-authority.js +0 -169
  238. package/dist/domain/connection-authority.js.map +0 -1
  239. package/dist/domain/environments.d.ts +0 -33
  240. package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
  241. package/dist/domain/host-mcp-task-admission.d.ts +0 -18
  242. package/dist/domain/packs.d.ts +0 -59
  243. package/dist/domain/product-integration-pack.d.ts +0 -44
  244. package/dist/domain/product-integration-skill.gen.d.ts +0 -5
  245. package/dist/remote-mcp-credentials.d.ts +0 -23
  246. package/dist/remote-mcp-credentials.js +0 -11
  247. package/dist/remote-mcp-credentials.js.map +0 -1
  248. package/src/application/host-mcp-owner.ts +0 -41
  249. package/src/application/host-mcp-resolvers.ts +0 -84
  250. package/src/domain/connection-authority.ts +0 -329
  251. package/src/domain/environments.ts +0 -120
  252. package/src/domain/host-mcp-authority-source-admission.ts +0 -25
  253. package/src/domain/host-mcp-task-admission.ts +0 -121
  254. package/src/domain/packs.ts +0 -699
  255. package/src/domain/product-integration-pack.ts +0 -42
  256. package/src/domain/product-integration-skill.gen.ts +0 -52
  257. package/src/remote-mcp-credentials.ts +0 -361
@@ -6,17 +6,29 @@ import {
6
6
  type ExternalIdentity,
7
7
  } from "@opengeni/contracts/external-identities";
8
8
  import {
9
+ accessContextHasInbox,
9
10
  verifyDelegatedAccessToken,
11
+ DEVELOPER_SETUP_API_KEY_PRESET,
12
+ normalizePermission,
13
+ organizationAccessPresetPermissions,
10
14
  type AccountGrant,
11
15
  type AccessContext,
12
16
  type AccessGrant,
17
+ type OrganizationApiKeyAccess,
18
+ OrganizationWorkspaceScope,
19
+ OPENGENI_USER_ACTIVITY_ACTIVE,
20
+ OPENGENI_USER_ACTIVITY_HEADER,
13
21
  Permission,
14
22
  type Workspace,
15
23
  } from "@opengeni/contracts";
16
24
  import {
17
25
  bootstrapWorkspace,
18
26
  ensureManagedAccessForUser,
27
+ getManagedUserProfilesByIds,
19
28
  ensureExternalIdentity,
29
+ ensureExternalWorkspaceMemberOnFirstUse,
30
+ USER_ISOLATION_WORKSPACE_SOURCE_PREFIX,
31
+ lockExternalWorkspaceMembershipLifecycle,
20
32
  resolveExternalIdentityLink,
21
33
  managedPersonalWorkspacePermissions,
22
34
  nestedPostgresSqlState,
@@ -31,12 +43,109 @@ import {
31
43
  } from "@opengeni/db";
32
44
  import type { Context } from "hono";
33
45
  import { HTTPException } from "hono/http-exception";
46
+ import { z } from "zod";
34
47
  import type { ManagedAuth } from "../managed-auth-type";
35
- import { getManagedSession } from "../managed-session";
48
+ import {
49
+ getManagedSession,
50
+ getNativeAppManagedSession,
51
+ NATIVE_APP_CREDENTIAL_PREFIX,
52
+ } from "../managed-session";
36
53
  import type { ManagedAuthSessionAdapter } from "../managed-auth-session-sets";
54
+ import type { UserPresenceRecorder } from "../user-presence";
55
+ import { serviceInitiatorFromHeaders } from "./service-initiator";
37
56
 
38
57
  const bearerPrefix = "Bearer ";
39
58
  const accessContextByRequest = new WeakMap<Request, Promise<AccessContext | null>>();
59
+ const accessResolvedRequests = new WeakSet<Request>();
60
+ const developerSetupApiKeyContexts = new WeakSet<AccessContext>();
61
+ const developerSetupAuthorizations = new WeakMap<AccessGrantAuthorization, AccessGrant>();
62
+ const developerSetupGrants = new WeakSet<AccessGrant>();
63
+ // Request-local permission semantics. The durable grant carries permissionMode;
64
+ // callers of the existing array API cannot accidentally expand a policy admin.
65
+ const explicitPermissionSets = new WeakSet<readonly Permission[]>();
66
+ function explicitPermissions(permissions: Permission[]): Permission[] {
67
+ explicitPermissionSets.add(permissions);
68
+ return permissions;
69
+ }
70
+
71
+ /** Only canonical authentication can prove setup-key provenance. */
72
+ export function isDeveloperSetupApiKeyContext(context: AccessContext): boolean {
73
+ return (
74
+ developerSetupApiKeyContexts.has(context) &&
75
+ accountScopedApiKeyWorkspaceAuthority(context) !== null
76
+ );
77
+ }
78
+
79
+ /** Provenance from canonical raw-key or verified restricted-token authentication. */
80
+ export function isDeveloperSetupAuthorization(
81
+ authorization: AccessGrantAuthorization | undefined,
82
+ ): boolean {
83
+ return (
84
+ authorization !== undefined &&
85
+ developerSetupAuthorizations.get(authorization) === authorization.grant
86
+ );
87
+ }
88
+
89
+ /** Exact grant objects resolved under authenticated setup-only provenance. */
90
+ export function isDeveloperSetupGrant(grant: AccessGrant): boolean {
91
+ return developerSetupGrants.has(grant);
92
+ }
93
+
94
+ /** Setup-derived credentials cannot carry literal organization or key authority. */
95
+ export function isDeveloperSetupDelegatedPermissionAllowed(permission: Permission): boolean {
96
+ return (
97
+ permission !== "secrets:read" &&
98
+ permission !== "api_keys:manage" &&
99
+ !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission)
100
+ );
101
+ }
102
+
103
+ /** Canonical authentication provenance, including the backing key of asUser. */
104
+ export function requireApiKeyManagementContext(context: AccessContext): void {
105
+ if (developerSetupApiKeyContexts.has(context)) {
106
+ throw new HTTPException(403, { message: "Developer setup keys cannot manage API keys" });
107
+ }
108
+ }
109
+
110
+ /** Membership is not an escape hatch for minting durable credentials. */
111
+ export function requireApiKeyDelegationContext(
112
+ context: AccessContext,
113
+ permissions: Permission[],
114
+ ): void {
115
+ const authority = accountScopedApiKeyWorkspaceAuthority(context);
116
+ const external = externalActorContexts.get(context);
117
+ const ceiling = authority ?? external;
118
+ if (
119
+ ceiling?.permissionMode === "explicit" &&
120
+ permissions.some((permission) => !ceiling.permissions.includes(permission))
121
+ ) {
122
+ throw new HTTPException(403, {
123
+ message: "cannot delegate a permission outside the organization key policy",
124
+ });
125
+ }
126
+ if (
127
+ ceiling?.permissionMode === "explicit" &&
128
+ permissions.includes("workspace:admin") &&
129
+ organizationAccessPresetPermissions("full").some(
130
+ (permission) =>
131
+ !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
132
+ !ceiling.permissions.includes(permission),
133
+ )
134
+ ) {
135
+ throw new HTTPException(403, {
136
+ message: "cannot delegate a legacy workspace-admin wildcard from a custom policy",
137
+ });
138
+ }
139
+ if (
140
+ (hasPermission(permissions, "api_keys:manage") ||
141
+ hasPermission(permissions, "members:manage")) &&
142
+ developerSetupApiKeyContexts.has(context)
143
+ ) {
144
+ throw new HTTPException(403, {
145
+ message: "Setup credentials cannot delegate API key management",
146
+ });
147
+ }
148
+ }
40
149
 
41
150
  /**
42
151
  * Contexts that were authenticated by a verified canonical managed cookie
@@ -58,16 +167,261 @@ const accessContextByRequest = new WeakMap<Request, Promise<AccessContext | null
58
167
  * the value the cookie branch produced.
59
168
  */
60
169
  const canonicalManagedCookieContexts = new WeakSet<AccessContext>();
170
+ /** Downstream legacy credentials cannot encode a partially selected admin. */
171
+ export function requireExplicitPermissionDelegation(
172
+ grant: Pick<AccessGrant, "permissions" | "permissionMode">,
173
+ requested: Permission[],
174
+ ): void {
175
+ if (
176
+ grant.permissionMode === "explicit" &&
177
+ requested.some((permission) => !grant.permissions.includes(permission))
178
+ ) {
179
+ throw new HTTPException(403, {
180
+ message: "cannot delegate a permission outside the organization key policy",
181
+ });
182
+ }
183
+ if (
184
+ grant.permissionMode === "explicit" &&
185
+ requested.includes("workspace:admin") &&
186
+ organizationAccessPresetPermissions("full").some(
187
+ (permission) =>
188
+ !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
189
+ !grant.permissions.includes(permission),
190
+ )
191
+ ) {
192
+ throw new HTTPException(403, {
193
+ message: "cannot delegate a legacy workspace-admin wildcard from a custom policy",
194
+ });
195
+ }
196
+ }
61
197
  const canonicalLocalHumanContexts = new WeakSet<AccessContext>();
198
+
199
+ export type DelegatedHumanAuthorization = Readonly<{
200
+ organizationId: string;
201
+ /** Exact native person subject, never an external identity or service. */
202
+ subjectId: string;
203
+ permissions: Permission[];
204
+ workspaceScope: OrganizationWorkspaceScope;
205
+ }>;
206
+
207
+ const delegatedHumanAuthorizationSchema = z.object({
208
+ organizationId: z.string().uuid(),
209
+ subjectId: z
210
+ .string()
211
+ .max(1024)
212
+ .regex(/^user:[^\s\u0000-\u001f\u007f]+$/),
213
+ permissions: z.array(Permission),
214
+ workspaceScope: OrganizationWorkspaceScope,
215
+ });
216
+
217
+ const delegatedHumanRequests = new WeakMap<Request, DelegatedHumanAuthorization>();
218
+ const delegatedHumanContexts = new WeakMap<AccessContext, DelegatedHumanAuthorization>();
219
+ const delegatedHumanProfileSchema = z.object({
220
+ id: z.string(),
221
+ name: z.string().nullable(),
222
+ email: z.string(),
223
+ emailVerified: z.boolean().optional(),
224
+ });
225
+
226
+ export type VerifiedDelegatedHumanContext = Readonly<{
227
+ /** Native identity projection only, never a browser/auth session. Absence of
228
+ * emailVerified means the native lookup did not prove email verification. */
229
+ user: Readonly<z.infer<typeof delegatedHumanProfileSchema>>;
230
+ subjectId: string;
231
+ authorization: DelegatedHumanAuthorization;
232
+ context: AccessContext;
233
+ }>;
234
+
235
+ const delegatedHumanProfiles = new WeakMap<AccessContext, VerifiedDelegatedHumanContext["user"]>();
236
+ const verifiedDelegatedHumanAuthorizations = new WeakMap<
237
+ AccessGrantAuthorization,
238
+ {
239
+ proof: DelegatedHumanAuthorization;
240
+ grant: AccessGrant;
241
+ accountGrant: AccountGrant;
242
+ }
243
+ >();
244
+
245
+ /**
246
+ * TRUSTED OAUTH VERIFIED DISPATCH ONLY. Before each stamp the in-process caller
247
+ * must validate the live OAuth grant, revocation, exact native person subject,
248
+ * organization, permission ceilings and workspace scope. This API does not
249
+ * verify OAuth and must NEVER be called with headers, metadata, token claims,
250
+ * a previous request's proof, or other unverified caller input.
251
+ *
252
+ * Stamp the exact raw Request that the actions dispatcher will pass to routes,
253
+ * before any access resolution. Cloning/wrapping a Request does not copy proof.
254
+ * The resolver reloads the real native person's live access on this request
255
+ * (and on fresh reauthorization); no session or cached grant is fabricated or
256
+ * transplanted. This proof never becomes managed-cookie/person-present proof.
257
+ */
258
+ export function stampDelegatedHumanAuthorization(
259
+ request: Request,
260
+ input: DelegatedHumanAuthorization,
261
+ ): void {
262
+ if (delegatedHumanRequests.has(request) || accessResolvedRequests.has(request)) {
263
+ throw new HTTPException(403, { message: "request access authorization already resolved" });
264
+ }
265
+ const parsed = delegatedHumanAuthorizationSchema.safeParse(input);
266
+ if (!parsed.success) {
267
+ throw new HTTPException(403, { message: "invalid delegated human authorization" });
268
+ }
269
+ const verified = parsed.data;
270
+ const workspaceScope: DelegatedHumanAuthorization["workspaceScope"] =
271
+ verified.workspaceScope.kind === "all"
272
+ ? { kind: "all" }
273
+ : { kind: "selected", workspaceIds: [...verified.workspaceScope.workspaceIds] };
274
+ if (workspaceScope.kind === "selected") Object.freeze(workspaceScope.workspaceIds);
275
+ Object.freeze(workspaceScope);
276
+ const proof: DelegatedHumanAuthorization = {
277
+ organizationId: verified.organizationId,
278
+ subjectId: verified.subjectId,
279
+ permissions: [...new Set(verified.permissions)],
280
+ workspaceScope,
281
+ };
282
+ Object.freeze(proof.permissions);
283
+ delegatedHumanRequests.set(request, Object.freeze(proof));
284
+ }
285
+
286
+ /** Token prefix of personal API keys, which act as the person who made them. */
287
+ export const PERSONAL_API_KEY_TOKEN_PREFIX = "ogp_";
288
+
289
+ /**
290
+ * A personal API key acts as its owner. Call once per incoming request, after
291
+ * any middleware that may rebuild the Request and before access resolution:
292
+ * an active personal key stamps this exact raw Request with its owner and
293
+ * access setting, so the request resolves through the same live-access path as
294
+ * a person's signed-in agent (their current membership, role and workspaces,
295
+ * capped by the key) and routes treat it as that person. Anything else is left
296
+ * to the ordinary resolver, which refuses an unknown or revoked key.
297
+ */
298
+ export async function stampPersonalApiKeyRequest(
299
+ request: Request,
300
+ deps: Pick<AccessDeps, "db">,
301
+ ): Promise<boolean> {
302
+ const authorization = request.headers.get("authorization");
303
+ if (!authorization?.startsWith(`${bearerPrefix}${PERSONAL_API_KEY_TOKEN_PREFIX}`)) return false;
304
+ if (
305
+ request.headers.has("x-opengeni-external-actor") ||
306
+ serviceInitiatorFromHeaders(request.headers)
307
+ ) {
308
+ throw new HTTPException(403, {
309
+ message: "A personal API key acts only as the person who created it.",
310
+ });
311
+ }
312
+ const apiKey = await findActiveApiKeyByHash(
313
+ deps.db,
314
+ await sha256Hex(authorization.slice(bearerPrefix.length)),
315
+ );
316
+ if (!apiKey || apiKey.credentialKind !== "personal" || !apiKey.ownerSubjectId) return false;
317
+ stampDelegatedHumanAuthorization(request, {
318
+ organizationId: apiKey.accountId,
319
+ subjectId: apiKey.ownerSubjectId,
320
+ permissions: (apiKey.ownerPermissions ?? []).flatMap((permission) => {
321
+ const parsed = Permission.safeParse(permission);
322
+ return parsed.success ? [parsed.data] : [];
323
+ }),
324
+ workspaceScope: apiKey.workspaceScope ?? { kind: "all" },
325
+ });
326
+ return true;
327
+ }
328
+
329
+ /** Immutable verified dispatch bounds for this exact raw Request, not a grant. */
330
+ export function verifiedDelegatedHumanAuthorizationForRequest(
331
+ request: Request,
332
+ ): DelegatedHumanAuthorization | null {
333
+ return delegatedHumanRequests.get(request) ?? null;
334
+ }
335
+
336
+ /**
337
+ * Resolve the exact verified OAuth-dispatch person and constrained live native
338
+ * access for this raw Request. Unstamped requests (even browser cookies) fail
339
+ * closed; callers must not fall back to a service or subject-shaped identity.
340
+ * Uses the same request-local native resolution as requireAccessContext.
341
+ *
342
+ * No session, session id or managed-cookie proof is created. emailVerified is
343
+ * forwarded only when the native profile lookup supplies it; missing is not
344
+ * verified and must never be filled from OAuth claims, headers or metadata.
345
+ */
346
+ export async function requireVerifiedDelegatedHumanContext(
347
+ c: Context,
348
+ deps: AccessDeps,
349
+ ): Promise<VerifiedDelegatedHumanContext> {
350
+ const authorization = verifiedDelegatedHumanAuthorizationForRequest(c.req.raw);
351
+ if (!authorization) {
352
+ throw new HTTPException(401, { message: "verified delegated human authorization required" });
353
+ }
354
+ const context = await requireAccessContext(c, deps);
355
+ const user = delegatedHumanProfiles.get(context);
356
+ if (delegatedHumanContexts.get(context) !== authorization || !user) {
357
+ throw new HTTPException(403, { message: "delegated native person authority is unavailable" });
358
+ }
359
+ return Object.freeze({ user, subjectId: context.subjectId, authorization, context });
360
+ }
361
+
362
+ /** Exact resolved owning-person authorization; clones and service claims fail.
363
+ * Supplying the raw Request additionally binds proof to that exact dispatch,
364
+ * not another request with the same native person and authorization bounds. */
365
+ export function isVerifiedDelegatedHumanAuthorization(
366
+ authorization: AccessGrantAuthorization,
367
+ request?: Request,
368
+ ): boolean {
369
+ const verified = verifiedDelegatedHumanAuthorizations.get(authorization);
370
+ return Boolean(
371
+ verified &&
372
+ (request === undefined || verified.proof === delegatedHumanRequests.get(request)) &&
373
+ authorization.contextIntegrity &&
374
+ !authorization.canonicalManagedHumanSession &&
375
+ !authorization.canonicalLocalHumanSession &&
376
+ authorization.grant === verified.grant &&
377
+ authorization.accountGrant === verified.accountGrant &&
378
+ authorization.authenticatedSubjectId === verified.proof.subjectId &&
379
+ verified.grant.subjectId === verified.proof.subjectId &&
380
+ verified.grant.accountId === verified.proof.organizationId &&
381
+ delegatedHumanWorkspaceAllowed(verified.proof, verified.grant.workspaceId),
382
+ );
383
+ }
384
+
385
+ function delegatedHumanWorkspaceAllowed(
386
+ proof: DelegatedHumanAuthorization,
387
+ workspaceId: string,
388
+ ): boolean {
389
+ return (
390
+ proof.workspaceScope.kind === "all" || proof.workspaceScope.workspaceIds.includes(workspaceId)
391
+ );
392
+ }
62
393
  const externalActorContexts = new WeakMap<
63
394
  AccessContext,
64
395
  {
65
396
  identity: ExternalIdentity;
66
397
  keyId: string;
67
398
  permissions: Permission[];
399
+ workspaceScope: OrganizationWorkspaceScope;
400
+ permissionMode: "legacy" | "explicit";
68
401
  linked?: NonNullable<Awaited<ReturnType<typeof resolveExternalIdentityLink>>>;
402
+ /**
403
+ * Plain external mode (no native link, no service-initiator attribution):
404
+ * the only lane where a missing shared-workspace membership may be created
405
+ * on first use. See {@link provisionExternalMemberOnFirstUse}.
406
+ */
407
+ firstUseMembership: boolean;
69
408
  }
70
409
  >();
410
+
411
+ /**
412
+ * Default permissions for a membership created on an external user's first
413
+ * request. Keep equal to `CONVERSATION_PERMISSIONS` in
414
+ * `packages/sdk/src/tenant-workspaces.ts`: conversation use only, no admin.
415
+ */
416
+ export const EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS: readonly Permission[] = [
417
+ "workspace:read",
418
+ "sessions:create",
419
+ "sessions:read",
420
+ "sessions:control",
421
+ "files:upload",
422
+ "files:read",
423
+ "mcp_servers:attach",
424
+ ];
71
425
  function attributionForExternalContext(context: AccessContext): ExternalActorAttribution {
72
426
  const external = externalActorContexts.get(context);
73
427
  if (!external) throw new Error("Verified external context required");
@@ -90,6 +444,7 @@ const verifiedExternalAuthorizations = new WeakMap<
90
444
  {
91
445
  grant: AccessGrant;
92
446
  workspaceId: string;
447
+ workspaceSettingsCeiling: boolean;
93
448
  identityReference: { externalId: string; source: string };
94
449
  attribution: ExternalActorAttribution;
95
450
  }
@@ -122,7 +477,7 @@ export function externalActorContinuationForAuthorization(
122
477
  return actor && verified ? { actor, identity: { ...verified.identityReference } } : null;
123
478
  }
124
479
 
125
- /** Dedicated owning-user proof. External admission never sets the native
480
+ /** Dedicated owning-user proof. External/OAuth admission never sets the native
126
481
  * cookie stamp. The resource/session layer still checks the exact owner. */
127
482
  export function hasVerifiedOwningUserAuthorization(
128
483
  authorization: AccessGrantAuthorization,
@@ -134,12 +489,22 @@ export function hasVerifiedOwningUserAuthorization(
134
489
  return false;
135
490
  return (
136
491
  authorization.canonicalManagedHumanSession ||
137
- externalAttributionForAuthorization(authorization, authorization.grant) !== null
492
+ externalAttributionForAuthorization(authorization, authorization.grant) !== null ||
493
+ isVerifiedDelegatedHumanAuthorization(authorization)
138
494
  );
139
495
  }
140
496
  const accountScopedApiKeyContexts = new WeakMap<
141
497
  AccessContext,
142
- Readonly<{ accountId: string; permissions: readonly Permission[] }>
498
+ Readonly<{
499
+ accountId: string;
500
+ permissions: readonly Permission[];
501
+ workspaceScope: OrganizationWorkspaceScope;
502
+ permissionMode: "legacy" | "explicit";
503
+ }>
504
+ >();
505
+ const apiKeyServiceContexts = new WeakMap<
506
+ AccessContext,
507
+ Pick<AccessGrant, "serviceInitiator" | "serviceInitiatorContext">
143
508
  >();
144
509
  const verifiedOrganizationServiceAuthorizations = new WeakMap<
145
510
  AccessGrantAuthorization,
@@ -163,6 +528,7 @@ const accountScopedApiKeyAccountPermissions = new Set<Permission>([
163
528
  "billing:read",
164
529
  "billing:manage",
165
530
  "api_keys:manage",
531
+ "usage_allowances:manage",
166
532
  ]);
167
533
  const accountScopedApiKeyWorkspaceExcludedPermissions = new Set<Permission>([
168
534
  "account:read",
@@ -170,13 +536,23 @@ const accountScopedApiKeyWorkspaceExcludedPermissions = new Set<Permission>([
170
536
  "workspace:create",
171
537
  "billing:read",
172
538
  "billing:manage",
539
+ "usage_allowances:manage",
173
540
  ]);
174
541
 
175
542
  export type AccountScopedApiKeyWorkspaceAuthority = Readonly<{
176
543
  accountId: string;
177
544
  permissions: Permission[];
545
+ workspaceScope: OrganizationWorkspaceScope;
546
+ permissionMode: "legacy" | "explicit";
178
547
  }>;
179
548
 
549
+ export function organizationWorkspaceInScope(
550
+ scope: OrganizationWorkspaceScope,
551
+ workspaceId: string,
552
+ ): boolean {
553
+ return scope.kind === "all" || scope.workspaceIds.includes(workspaceId.toLowerCase());
554
+ }
555
+
180
556
  /**
181
557
  * Return account-scoped API-key workspace authority only for the exact
182
558
  * AccessContext object stamped by successful API-key authentication. Subject
@@ -200,10 +576,27 @@ export function accountScopedApiKeyWorkspaceAuthority(
200
576
  }
201
577
  return {
202
578
  accountId: authority.accountId,
203
- permissions: [...authority.permissions],
579
+ permissions:
580
+ authority.permissionMode === "explicit"
581
+ ? explicitPermissions([...authority.permissions])
582
+ : [...authority.permissions],
583
+ workspaceScope: authority.workspaceScope,
584
+ permissionMode: authority.permissionMode,
204
585
  };
205
586
  }
206
587
 
588
+ /** Classify stored scopes without changing the legacy full/read permission sets. */
589
+ export function organizationApiKeyAccess(permissions: Permission[]): OrganizationApiKeyAccess {
590
+ if (
591
+ permissions.length === DEVELOPER_SETUP_API_KEY_PRESET.permissions.length &&
592
+ DEVELOPER_SETUP_API_KEY_PRESET.permissions.every((permission) =>
593
+ permissions.includes(permission),
594
+ )
595
+ )
596
+ return "developer_setup";
597
+ return permissions.includes("workspace:admin") ? "full" : "read";
598
+ }
599
+
207
600
  /**
208
601
  * Opaque, request-local proof that the canonical access resolver authorized the
209
602
  * exact account administrator named by the stamp. The random id is audit and
@@ -222,6 +615,8 @@ export type AccessDeps = {
222
615
  settings: Settings;
223
616
  managedAuth?: ManagedAuth | null;
224
617
  managedAuthSessionAdapter?: ManagedAuthSessionAdapter | null;
618
+ /** Analytics only: notes canonical managed-cookie activity, never authority. */
619
+ userPresence?: UserPresenceRecorder | null;
225
620
  };
226
621
 
227
622
  /** null means this is not an authenticated external lane; [] means that lane
@@ -245,10 +640,16 @@ export async function listExternalActorWorkspaces(
245
640
  const personal = await withAccountRls(deps.db, actor.identity.accountId, (tx) =>
246
641
  requireWorkspace(tx, actor.linked?.personalWorkspaceId ?? actor.identity.personalWorkspaceId),
247
642
  );
248
- if (personal.accountId === actor.identity.accountId && personal.kind === "personal")
643
+ if (
644
+ personal.accountId === actor.identity.accountId &&
645
+ personal.kind === "personal" &&
646
+ actor.permissionMode === "legacy" &&
647
+ organizationWorkspaceInScope(actor.workspaceScope, personal.id)
648
+ )
249
649
  authorized.push(personal);
250
650
  for (const workspace of candidates) {
251
651
  if (workspace.accountId !== actor.identity.accountId || workspace.kind !== "shared") continue;
652
+ if (!organizationWorkspaceInScope(actor.workspaceScope, workspace.id)) continue;
252
653
  const grant = await withWorkspaceSubjectRls(deps.db, workspace.id, context.subjectId, (tx) =>
253
654
  getWorkspaceGrant(tx, context.subjectId, workspace.id),
254
655
  );
@@ -305,8 +706,9 @@ export type AccessGrantAuthorization = {
305
706
  contextIntegrity: boolean;
306
707
  /**
307
708
  * Did this request authenticate as the canonical managed-cookie (Better Auth)
308
- * session that OWNS this grant's subject? The single input to the owner-only
309
- * managed personal-workspace exception; see `isCanonicalManagedHumanSession`.
709
+ * session that OWNS this grant's subject? Cookie/person-present ceremonies
710
+ * retain this strict gate; owning-user boundaries use
711
+ * `hasVerifiedOwningUserAuthorization` instead.
310
712
  * False for every bearer, API-key, delegated, service, local, and configured
311
713
  * principal, and for any future path that does not verify a cookie.
312
714
  */
@@ -348,6 +750,10 @@ export function accessGrantAuthorizationFromContext(
348
750
  canonicalLocalHumanSession: isCanonicalLocalHumanSession(context, grant),
349
751
  };
350
752
  resolvedAccessGrantAuthorizations.add(authorization);
753
+ if (contextIntegrity && developerSetupApiKeyContexts.has(context)) {
754
+ developerSetupAuthorizations.set(authorization, grant);
755
+ developerSetupGrants.add(grant);
756
+ }
351
757
  if (
352
758
  contextIntegrity &&
353
759
  accountScopedApiKeyWorkspaceAuthority(context)?.accountId === grant.accountId &&
@@ -360,6 +766,9 @@ export function accessGrantAuthorizationFromContext(
360
766
  verifiedExternalAuthorizations.set(authorization, {
361
767
  grant,
362
768
  workspaceId: grant.workspaceId,
769
+ workspaceSettingsCeiling:
770
+ hasPermission(external.permissions, "workspace:admin") &&
771
+ (!external.linked || hasPermission(external.linked.link.permissions, "workspace:admin")),
363
772
  identityReference: {
364
773
  externalId: external.identity.externalId,
365
774
  source: external.identity.source,
@@ -367,6 +776,27 @@ export function accessGrantAuthorizationFromContext(
367
776
  attribution: attributionForExternalContext(context),
368
777
  });
369
778
  }
779
+ const delegatedHuman = delegatedHumanContexts.get(context);
780
+ if (
781
+ delegatedHuman &&
782
+ contextIntegrity &&
783
+ context.workspaceGrants.includes(grant) &&
784
+ grant.accountId === delegatedHuman.organizationId &&
785
+ grant.subjectId === delegatedHuman.subjectId &&
786
+ grant.principalKind === "human_session" &&
787
+ !grant.serviceInitiator &&
788
+ !grant.serviceInitiatorContext &&
789
+ delegatedHumanWorkspaceAllowed(delegatedHuman, grant.workspaceId)
790
+ ) {
791
+ verifiedDelegatedHumanAuthorizations.set(authorization, {
792
+ proof: delegatedHuman,
793
+ grant,
794
+ accountGrant: matchingAccountGrants[0]!,
795
+ });
796
+ // Keep the exact proof-bearing value immutable: changing a cookie flag,
797
+ // subject or grant in place must not manufacture browser authentication.
798
+ Object.freeze(authorization);
799
+ }
370
800
  return authorization;
371
801
  }
372
802
 
@@ -464,13 +894,19 @@ export async function requireAccessGrantAuthorization(
464
894
  permission?: Permission,
465
895
  ): Promise<AccessGrantAuthorization> {
466
896
  const context = await requireAccessContext(c, deps);
467
- return await accessGrantAuthorization(context, deps, workspaceId, permission);
897
+ // Request entry only: a fresh re-check of a live connection never re-creates
898
+ // a membership that was removed while the connection was open.
899
+ return await accessGrantAuthorization(context, deps, workspaceId, permission, {
900
+ firstUseMembership: true,
901
+ });
468
902
  }
469
903
 
470
904
  /**
471
905
  * Settings administration is narrower than workspace administration. The
472
- * canonical managed-cookie owner may configure their Personal workspace, but
906
+ * verified owning user may configure their Personal workspace, but
473
907
  * never acquires the admin wildcard (and its membership/delegation powers).
908
+ * Delegated owners also need workspace:admin in their verified dispatch/key
909
+ * ceiling: a read-only delegation cannot turn ownership into a settings write.
474
910
  * Only use this boundary for workspace configuration, not access management.
475
911
  */
476
912
  export async function requireWorkspaceSettingsGrant(
@@ -481,8 +917,15 @@ export async function requireWorkspaceSettingsGrant(
481
917
  const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
482
918
  const grant = requireResolvedAccessGrantAuthorization(authorization, workspaceId);
483
919
  if (hasPermission(grant.permissions, "workspace:admin")) return grant;
920
+ const delegatedHuman = verifiedDelegatedHumanAuthorizations.get(authorization);
921
+ const ownerSettingsCeiling =
922
+ authorization.canonicalManagedHumanSession ||
923
+ verifiedExternalAuthorizations.get(authorization)?.workspaceSettingsCeiling === true ||
924
+ (delegatedHuman !== undefined && delegatedHuman.proof.permissions.includes("workspace:admin"));
484
925
  if (
485
- authorization.canonicalManagedHumanSession &&
926
+ hasVerifiedOwningUserAuthorization(authorization) &&
927
+ ownerSettingsCeiling &&
928
+ hasPermission(grant.permissions, "workspace:read") &&
486
929
  (await resolveNamedManagedPersonalWorkspaceGrant(deps.db, {
487
930
  accountId: grant.accountId,
488
931
  workspaceId,
@@ -496,29 +939,183 @@ export async function requireWorkspaceSettingsGrant(
496
939
  });
497
940
  }
498
941
 
942
+ /**
943
+ * Authority for a shared workspace's own Members surface (list, candidates,
944
+ * add, change, remove). A holder of the requested workspace permission keeps
945
+ * the ordinary grant. Otherwise an active organization owner or administrator,
946
+ * authenticated by the canonical managed cookie, manages any shared workspace
947
+ * in their organization exactly as through the organization control plane,
948
+ * with or without an operational membership row there. Every other principal
949
+ * (API keys, delegated bearers, agents, services, local/configured access)
950
+ * and every Personal workspace keeps the original refusal. The database
951
+ * functions re-derive the organization role under the organization fence.
952
+ */
953
+ export async function requireWorkspaceMemberManagementAuthority(
954
+ c: Context,
955
+ deps: AccessDeps,
956
+ workspaceId: string,
957
+ permission: "members:manage" | "workspace:read",
958
+ ): Promise<{ grant: AccessGrant; organizationAdministrator: boolean }> {
959
+ try {
960
+ return {
961
+ grant: await requireAccessGrant(c, deps, workspaceId, permission),
962
+ organizationAdministrator: false,
963
+ };
964
+ } catch (error) {
965
+ if (!(error instanceof HTTPException) || error.status !== 403) throw error;
966
+ const context = await requireAccessContext(c, deps);
967
+ if (!canonicalManagedCookieContexts.has(context) || !context.subjectId.startsWith("user:")) {
968
+ throw error;
969
+ }
970
+ const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
971
+ if (!workspace || workspace.kind !== "shared") throw error;
972
+ const organizationRole = context.accountGrants.find(
973
+ (candidate) =>
974
+ candidate.accountId === workspace.accountId && candidate.subjectId === context.subjectId,
975
+ )?.role;
976
+ if (organizationRole !== "owner" && organizationRole !== "admin") throw error;
977
+ return {
978
+ grant: {
979
+ workspaceId,
980
+ accountId: workspace.accountId,
981
+ subjectId: context.subjectId,
982
+ ...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
983
+ principalKind: "human_session",
984
+ permissions: ["workspace:read", "members:manage"],
985
+ },
986
+ organizationAdministrator: true,
987
+ };
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Automatic membership for an organization key acting as an external user
993
+ * (`asUser`): the first request to a shared workspace in the key's own
994
+ * organization creates the user's missing membership with
995
+ * {@link EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS}, then the request continues.
996
+ * It is exactly the authority of explicit `addExternalWorkspaceMember`: the
997
+ * calling key must hold `members:manage` (or legacy `workspace:admin`) plus
998
+ * every default permission, with the workspace in its scope, re-checked live
999
+ * under the organization membership fence together with the identity's own
1000
+ * active status. Never for linked native identities, service-initiator
1001
+ * requests, or any non-key principal (agent attempts, delegated/bearer user
1002
+ * tokens, browser sessions), which never reach the external lane; never for
1003
+ * Personal or SDK per-user workspaces; never when the request needs a
1004
+ * permission outside the defaults. Never changes an existing membership.
1005
+ * Returns true only when a membership now exists; any refusal leaves the
1006
+ * ordinary 403 in place.
1007
+ */
1008
+ async function provisionExternalMemberOnFirstUse(
1009
+ deps: AccessDeps,
1010
+ context: AccessContext,
1011
+ external: NonNullable<ReturnType<typeof externalActorContexts.get>>,
1012
+ workspaceId: string,
1013
+ permission: Permission | undefined,
1014
+ ): Promise<boolean> {
1015
+ if (
1016
+ !external.firstUseMembership ||
1017
+ external.linked ||
1018
+ // A request that would 403 on its own permission anyway creates nothing.
1019
+ (permission !== undefined && !EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS.includes(permission)) ||
1020
+ context.subjectId !== external.identity.subjectId ||
1021
+ !hasPermission(external.permissions, "members:manage", external.permissionMode) ||
1022
+ EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS.some(
1023
+ (required) => !hasPermission(external.permissions, required, external.permissionMode),
1024
+ )
1025
+ )
1026
+ return false;
1027
+ const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
1028
+ if (
1029
+ !workspace ||
1030
+ workspace.kind !== "shared" ||
1031
+ workspace.accountId !== external.identity.accountId ||
1032
+ // SDK per-user workspaces stay single-user: the SDK adds their owner.
1033
+ workspace.externalSource?.startsWith(USER_ISOLATION_WORKSPACE_SOURCE_PREFIX)
1034
+ )
1035
+ return false;
1036
+ try {
1037
+ await ensureExternalWorkspaceMemberOnFirstUse(
1038
+ deps.db,
1039
+ {
1040
+ organizationId: external.identity.accountId,
1041
+ workspaceId,
1042
+ actorSubjectId: `api_key:${external.keyId}`,
1043
+ },
1044
+ {
1045
+ subjectId: external.identity.subjectId,
1046
+ identity: { source: external.identity.source, externalId: external.identity.externalId },
1047
+ permissions: EXTERNAL_FIRST_USE_MEMBER_PERMISSIONS,
1048
+ },
1049
+ );
1050
+ return true;
1051
+ } catch (error) {
1052
+ // A changed/revoked key, narrowed policy or workspace scope keeps the
1053
+ // ordinary denial. Anything else is an infrastructure failure.
1054
+ if (
1055
+ (error as { code?: unknown } | null)?.code === "42501" ||
1056
+ nestedPostgresSqlState(error) === "42501"
1057
+ )
1058
+ return false;
1059
+ throw error;
1060
+ }
1061
+ }
1062
+
499
1063
  async function accessGrantAuthorization(
500
1064
  context: AccessContext,
501
1065
  deps: AccessDeps,
502
1066
  workspaceId: string,
503
1067
  permission?: Permission,
1068
+ options: { firstUseMembership?: boolean } = {},
504
1069
  ): Promise<AccessGrantAuthorization> {
1070
+ // No named-subject or organization-key fallback may widen verified OAuth
1071
+ // bounds. These grants came from this resolution's live native access only.
1072
+ if (delegatedHumanContexts.has(context)) {
1073
+ const grant = context.workspaceGrants.find(
1074
+ (candidate) => candidate.workspaceId === workspaceId,
1075
+ );
1076
+ if (!grant)
1077
+ throw new HTTPException(403, { message: "delegated human workspace access denied" });
1078
+ if (permission) requirePermission(grant, permission);
1079
+ return accessGrantAuthorizationFromContext(context, grant);
1080
+ }
505
1081
  const external = externalActorContexts.get(context);
506
1082
  if (external) {
507
- const grant: AccessGrant | null =
1083
+ if (!organizationWorkspaceInScope(external.workspaceScope, workspaceId)) {
1084
+ throw new HTTPException(403, { message: "workspace is outside organization key scope" });
1085
+ }
1086
+ if (external.permissionMode === "explicit") {
1087
+ const workspace = await requireWorkspace(deps.db, workspaceId);
1088
+ if (workspace.kind !== "shared" || workspace.accountId !== external.identity.accountId)
1089
+ throw new HTTPException(403, {
1090
+ message: "organization policy requires a shared workspace",
1091
+ });
1092
+ }
1093
+ const personal =
508
1094
  workspaceId ===
509
- (external.linked?.personalWorkspaceId ?? external.identity.personalWorkspaceId)
510
- ? {
511
- accountId: external.identity.accountId,
512
- workspaceId,
513
- subjectId: context.subjectId,
514
- principalKind: "human_session",
515
- permissions: [...managedPersonalWorkspacePermissions],
516
- }
517
- : await withWorkspaceSubjectRls(deps.db, workspaceId, context.subjectId, (tx) =>
518
- getWorkspaceGrant(tx, context.subjectId, workspaceId, {
519
- principalKind: "human_session",
520
- }),
521
- );
1095
+ (external.linked?.personalWorkspaceId ?? external.identity.personalWorkspaceId);
1096
+ const membershipGrant = () =>
1097
+ withWorkspaceSubjectRls(deps.db, workspaceId, context.subjectId, (tx) =>
1098
+ getWorkspaceGrant(tx, context.subjectId, workspaceId, {
1099
+ principalKind: "human_session",
1100
+ }),
1101
+ );
1102
+ let grant: AccessGrant | null = personal
1103
+ ? {
1104
+ accountId: external.identity.accountId,
1105
+ workspaceId,
1106
+ subjectId: context.subjectId,
1107
+ principalKind: "human_session",
1108
+ permissions: [...managedPersonalWorkspacePermissions],
1109
+ }
1110
+ : await membershipGrant();
1111
+ if (
1112
+ !grant &&
1113
+ !personal &&
1114
+ options.firstUseMembership === true &&
1115
+ (await provisionExternalMemberOnFirstUse(deps, context, external, workspaceId, permission))
1116
+ ) {
1117
+ grant = await membershipGrant();
1118
+ }
522
1119
  if (!grant || grant.accountId !== external.identity.accountId) {
523
1120
  throw new HTTPException(403, { message: "external workspace access denied" });
524
1121
  }
@@ -531,6 +1128,10 @@ async function accessGrantAuthorization(
531
1128
  hasPermission(external.permissions, value) &&
532
1129
  (!external.linked || hasPermission(external.linked.link.permissions, value)),
533
1130
  );
1131
+ if (external.permissionMode === "explicit") {
1132
+ grant.permissionMode = "explicit";
1133
+ explicitPermissions(grant.permissions);
1134
+ }
534
1135
  grant.metadata = {
535
1136
  ...grant.metadata,
536
1137
  externalActor: attributionForExternalContext(context),
@@ -538,6 +1139,30 @@ async function accessGrantAuthorization(
538
1139
  if (permission) requirePermission(grant, permission);
539
1140
  return accessGrantAuthorizationFromContext(context, grant);
540
1141
  }
1142
+ const organizationKey = accountScopedApiKeyWorkspaceAuthority(context);
1143
+ if (organizationKey) {
1144
+ const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
1145
+ if (!workspace) throw new HTTPException(404, { message: "workspace not found" });
1146
+ if (
1147
+ workspace.accountId !== organizationKey.accountId ||
1148
+ workspace.kind !== "shared" ||
1149
+ !organizationWorkspaceInScope(organizationKey.workspaceScope, workspace.id)
1150
+ ) {
1151
+ throw new HTTPException(403, { message: "workspace is outside organization key scope" });
1152
+ }
1153
+ const grant: AccessGrant = {
1154
+ accountId: workspace.accountId,
1155
+ workspaceId,
1156
+ subjectId: context.subjectId,
1157
+ ...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
1158
+ principalKind: "api_key",
1159
+ permissions: organizationKey.permissions,
1160
+ permissionMode: organizationKey.permissionMode,
1161
+ ...apiKeyServiceContexts.get(context),
1162
+ };
1163
+ requirePermission(grant, permission ?? "workspace:read");
1164
+ return accessGrantAuthorizationFromContext(context, grant);
1165
+ }
541
1166
  const principalKind = hostedHumanSessionPrincipalKind(context);
542
1167
  let grant =
543
1168
  context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ??
@@ -547,6 +1172,28 @@ async function accessGrantAuthorization(
547
1172
  workspaceId,
548
1173
  principalKind ? { principalKind } : undefined,
549
1174
  ));
1175
+ if (grant && isDeveloperSetupApiKeyContext(context)) {
1176
+ const authority = accountScopedApiKeyWorkspaceAuthority(context);
1177
+ const workspace = await requireWorkspace(deps.db, workspaceId);
1178
+ if (!authority || workspace.accountId !== authority.accountId || workspace.kind !== "shared") {
1179
+ throw new HTTPException(403, {
1180
+ message: "Developer setup requires a shared organization workspace",
1181
+ });
1182
+ }
1183
+ // A persisted creator/membership grant cannot widen the authenticated key
1184
+ // ceiling, including literal secret reads and inherited session authority.
1185
+ const storedPermissions = grant.permissions;
1186
+ grant = {
1187
+ ...grant,
1188
+ permissions: Permission.options.filter(
1189
+ (value) =>
1190
+ value !== "api_keys:manage" &&
1191
+ !accountScopedApiKeyWorkspaceExcludedPermissions.has(value) &&
1192
+ hasPermission(storedPermissions, value) &&
1193
+ hasPermission(authority.permissions, value),
1194
+ ),
1195
+ };
1196
+ }
550
1197
  if (!grant) {
551
1198
  const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
552
1199
  if (!workspace) {
@@ -567,6 +1214,7 @@ async function accessGrantAuthorization(
567
1214
  ...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
568
1215
  permissions: authority.permissions,
569
1216
  principalKind: "api_key",
1217
+ ...apiKeyServiceContexts.get(context),
570
1218
  };
571
1219
  } else {
572
1220
  throw new HTTPException(403, { message: "workspace access denied" });
@@ -579,7 +1227,7 @@ async function accessGrantAuthorization(
579
1227
  }
580
1228
 
581
1229
  /**
582
- * May this grant use the owner-only managed personal-workspace exception?
1230
+ * Did the managed cookie authenticate this exact native person?
583
1231
  *
584
1232
  * A managed human's personal workspace carries no `workspace_memberships` row,
585
1233
  * so seams that fence on one must consult the
@@ -615,6 +1263,30 @@ function isCanonicalManagedHumanSession(context: AccessContext, grant: AccessGra
615
1263
  );
616
1264
  }
617
1265
 
1266
+ /**
1267
+ * The subject whose inbox this request may read, or null when it has none.
1268
+ * A signed-in person (`user:`, no key) requires verified managed-human
1269
+ * provenance, including approved native app credentials. The local
1270
+ * install's human qualifies only when the in-process local bootstrap produced
1271
+ * this context (so a delegated bearer naming `dev` cannot borrow it), with the
1272
+ * human-session, non-delegated, non-service, keyless shape of
1273
+ * {@link accessContextHasInbox}. An agent acting as a person (verified
1274
+ * in-process dispatch) reads that person's inbox within its organization and
1275
+ * workspaces: the inbox routes check each item's workspace on this context.
1276
+ */
1277
+ export function inboxSubjectForContext(context: AccessContext): string | null {
1278
+ if (!accessContextHasInbox(context)) return null;
1279
+ if (context.subjectId.startsWith("user:")) {
1280
+ return canonicalManagedCookieContexts.has(context) || delegatedHumanContexts.has(context)
1281
+ ? context.subjectId
1282
+ : null;
1283
+ }
1284
+ return canonicalLocalHumanContexts.has(context) &&
1285
+ context.workspaceGrants.every((grant) => isCanonicalLocalHumanSession(context, grant))
1286
+ ? context.subjectId
1287
+ : null;
1288
+ }
1289
+
618
1290
  function isCanonicalLocalHumanSession(context: AccessContext, grant: AccessGrant): boolean {
619
1291
  return (
620
1292
  canonicalLocalHumanContexts.has(context) &&
@@ -642,6 +1314,7 @@ function hostedHumanSessionPrincipalKind(context: AccessContext): "human_session
642
1314
  }
643
1315
 
644
1316
  export function requirePermission(grant: AccessGrant, permission: Permission): void {
1317
+ if (grant.permissionMode === "explicit") explicitPermissions(grant.permissions);
645
1318
  if (!hasPermission(grant.permissions, permission)) {
646
1319
  if (permission === "variable-sets:use") {
647
1320
  throw new HTTPException(403, {
@@ -673,23 +1346,63 @@ export function requireLiteralPermission(grant: AccessGrant, permission: Permiss
673
1346
  }
674
1347
  }
675
1348
 
676
- export function hasLiteralPermission(permissions: Permission[], permission: Permission): boolean {
1349
+ export function hasLiteralPermission(
1350
+ permissions: readonly Permission[],
1351
+ permission: Permission,
1352
+ ): boolean {
677
1353
  if (!Array.isArray(permissions)) return false;
678
1354
  return permissions.includes(permission);
679
1355
  }
680
1356
 
681
- export function hasPermission(permissions: Permission[], permission: Permission): boolean {
1357
+ export function hasPermission(
1358
+ permissions: readonly Permission[],
1359
+ permission: Permission,
1360
+ permissionMode?: AccessGrant["permissionMode"],
1361
+ ): boolean {
682
1362
  if (!Array.isArray(permissions)) return false;
683
1363
  if (permission === "secrets:read") {
684
1364
  return permissions.includes("secrets:read");
685
1365
  }
1366
+ const granted = permissions.includes(permission) || grantsSecretAlias(permissions, permission);
1367
+ if (permissionMode === "explicit" || explicitPermissionSets.has(permissions)) return granted;
686
1368
  // Variable-set metadata, plaintext read, write/rotation, attachment, and
687
1369
  // runtime use are independent capabilities. Deprecated broad permissions
688
1370
  // remain parseable but do not imply any of the exact permissions.
689
- return permissions.includes(permission) || permissions.includes("workspace:admin");
1371
+ return granted || permissions.includes("workspace:admin");
1372
+ }
1373
+
1374
+ /**
1375
+ * Stored roles and API keys keep their legacy variable-set names (Q-16): a
1376
+ * canonical `secrets:*` requirement is met by any granted name that aliases to
1377
+ * it. Legacy requirements (the variable-set routes, until they are removed)
1378
+ * keep their exact meaning. `secrets:read` is never aliased.
1379
+ */
1380
+ function grantsSecretAlias(permissions: readonly Permission[], permission: Permission): boolean {
1381
+ if (normalizePermission(permission) !== permission) return false;
1382
+ return permissions.some(
1383
+ (granted) => granted !== permission && normalizePermission(granted) === permission,
1384
+ );
690
1385
  }
691
1386
 
692
1387
  async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
1388
+ accessResolvedRequests.add(c.req.raw);
1389
+ const delegatedHuman = verifiedDelegatedHumanAuthorizationForRequest(c.req.raw);
1390
+ if (delegatedHuman) return resolveDelegatedHumanAccessContext(deps, delegatedHuman);
1391
+ const service = serviceInitiatorFromHeaders(c.req.raw.headers);
1392
+ if (service) {
1393
+ if (deps.settings.productAccessMode === "local") {
1394
+ throw new HTTPException(422, {
1395
+ message: "service initiator headers require an organization or workspace API key",
1396
+ });
1397
+ }
1398
+ const context = await apiKeyAccessContext(c, deps, deps.settings.productAccessMode);
1399
+ if (!context) {
1400
+ throw new HTTPException(422, {
1401
+ message: "service initiator headers require an organization or workspace API key",
1402
+ });
1403
+ }
1404
+ return context;
1405
+ }
693
1406
  if (c.req.header("x-opengeni-external-actor") !== undefined) {
694
1407
  if (deps.settings.productAccessMode === "local") {
695
1408
  throw new HTTPException(401, {
@@ -743,6 +1456,24 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
743
1456
 
744
1457
  const bearer = bearerToken(c);
745
1458
  if (bearer) {
1459
+ if (bearer.startsWith(NATIVE_APP_CREDENTIAL_PREFIX)) {
1460
+ // A native app account: the person approved this device from a signed-in
1461
+ // browser, so it carries the same human authority as that browser.
1462
+ if (!deps.managedAuth) return null;
1463
+ const session = await getNativeAppManagedSession(deps.managedAuth, bearer, deps.db);
1464
+ if (!session?.user) return null;
1465
+ const context = await ensureManagedAccessForUser(deps.db, {
1466
+ userId: session.user.id,
1467
+ email: session.user.email,
1468
+ name: session.user.name,
1469
+ emailVerified: session.user.emailVerified,
1470
+ provisionFallbackOrganization: false,
1471
+ bindPendingInvitations: false,
1472
+ });
1473
+ canonicalManagedCookieContexts.add(context);
1474
+ recordUserPresence(c, deps, context.subjectId);
1475
+ return context;
1476
+ }
746
1477
  const delegated = await delegatedAccessContext(c, deps, "managed", bearer);
747
1478
  if (delegated) {
748
1479
  return delegated;
@@ -773,6 +1504,7 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
773
1504
  bindPendingInvitations: false,
774
1505
  });
775
1506
  canonicalManagedCookieContexts.add(context);
1507
+ recordUserPresence(c, deps, context.subjectId);
776
1508
  return context;
777
1509
  }
778
1510
  }
@@ -780,6 +1512,124 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
780
1512
  return null;
781
1513
  }
782
1514
 
1515
+ async function resolveDelegatedHumanAccessContext(
1516
+ deps: AccessDeps,
1517
+ proof: DelegatedHumanAuthorization,
1518
+ ): Promise<AccessContext> {
1519
+ if (deps.settings.productAccessMode === "local") {
1520
+ throw new HTTPException(403, {
1521
+ message: "delegated humans require native organization access",
1522
+ });
1523
+ }
1524
+ const userId = proof.subjectId.slice("user:".length);
1525
+ const profiles = await getManagedUserProfilesByIds(deps.db, [userId]);
1526
+ const parsedProfile = delegatedHumanProfileSchema.safeParse(
1527
+ profiles.length === 1 && profiles[0]?.id === userId ? profiles[0] : null,
1528
+ );
1529
+ if (!parsedProfile.success)
1530
+ throw new HTTPException(403, { message: "delegated native person is unavailable" });
1531
+ const profile = parsedProfile.data;
1532
+ const live = await ensureManagedAccessForUser(deps.db, {
1533
+ userId: profile.id,
1534
+ email: profile.email,
1535
+ name: profile.name ?? "",
1536
+ provisionFallbackOrganization: false,
1537
+ bindPendingInvitations: false,
1538
+ });
1539
+ const accounts = live.accountGrants.filter((grant) => grant.accountId === proof.organizationId);
1540
+ if (
1541
+ live.mode !== "managed" ||
1542
+ live.subjectId !== proof.subjectId ||
1543
+ accounts.length !== 1 ||
1544
+ live.accountGrants.some((grant) => grant.subjectId !== proof.subjectId) ||
1545
+ live.workspaceGrants.some(
1546
+ (grant) =>
1547
+ grant.subjectId !== proof.subjectId ||
1548
+ grant.principalKind !== "human_session" ||
1549
+ grant.metadata?.delegated === true ||
1550
+ grant.serviceInitiator ||
1551
+ grant.serviceInitiatorContext,
1552
+ )
1553
+ ) {
1554
+ throw new HTTPException(403, { message: "delegated native person authority is unavailable" });
1555
+ }
1556
+ const verifiedPermissions = [...proof.permissions];
1557
+ // The person's own role expands as usual; the connection's access setting is
1558
+ // literal, and so is the result: workspace:admin in a Custom setting is never
1559
+ // a wildcard for the permissions the person left out.
1560
+ const intersectWorkspacePermissions = (permissions: Permission[]) =>
1561
+ explicitPermissions(
1562
+ Permission.options.filter(
1563
+ (permission) =>
1564
+ hasPermission(permissions, permission) &&
1565
+ hasPermission(verifiedPermissions, permission, "explicit"),
1566
+ ),
1567
+ );
1568
+ const accountGrant: AccountGrant = {
1569
+ ...accounts[0]!,
1570
+ // Organization/billing authority must be literal on BOTH sides, and so is
1571
+ // the result: a workspace:admin entry here never reads as account:admin or
1572
+ // billing to a later permission check.
1573
+ permissions: explicitPermissions(
1574
+ Permission.options.filter(
1575
+ (permission) =>
1576
+ hasLiteralPermission(accounts[0]!.permissions, permission) &&
1577
+ hasLiteralPermission(verifiedPermissions, permission),
1578
+ ),
1579
+ ),
1580
+ };
1581
+ const workspaceGrants = live.workspaceGrants
1582
+ .filter(
1583
+ (grant) =>
1584
+ grant.accountId === proof.organizationId &&
1585
+ delegatedHumanWorkspaceAllowed(proof, grant.workspaceId),
1586
+ )
1587
+ .map((grant) => ({
1588
+ ...grant,
1589
+ permissions: intersectWorkspacePermissions(grant.permissions),
1590
+ permissionMode: "explicit" as const,
1591
+ }));
1592
+ // Proof-bearing authority cannot be changed in place and then reused as if
1593
+ // the resolver had authenticated a different subject, scope or ceiling.
1594
+ for (const grant of [accountGrant, ...workspaceGrants]) {
1595
+ Object.freeze(grant.permissions);
1596
+ Object.freeze(grant);
1597
+ }
1598
+ const context: AccessContext = {
1599
+ mode: "managed",
1600
+ subjectId: proof.subjectId,
1601
+ ...(live.subjectLabel ? { subjectLabel: live.subjectLabel } : {}),
1602
+ accountGrants: [accountGrant],
1603
+ workspaceGrants,
1604
+ defaultAccountId: proof.organizationId,
1605
+ defaultWorkspaceId:
1606
+ workspaceGrants.find((grant) => grant.workspaceId === live.defaultWorkspaceId)?.workspaceId ??
1607
+ workspaceGrants[0]?.workspaceId ??
1608
+ null,
1609
+ };
1610
+ Object.freeze(context.accountGrants);
1611
+ Object.freeze(context.workspaceGrants);
1612
+ Object.freeze(context);
1613
+ delegatedHumanContexts.set(context, proof);
1614
+ delegatedHumanProfiles.set(context, Object.freeze(profile));
1615
+ return context;
1616
+ }
1617
+
1618
+ /**
1619
+ * Presence counts people, so only the verified managed browser-session branch
1620
+ * reports it, and only for a request the console marked as human activity (a
1621
+ * visible tab with recent interaction). Each request counts once: an SSE
1622
+ * stream's periodic reauthorization reuses its original request.
1623
+ */
1624
+ const presenceRecordedRequests = new WeakSet<Request>();
1625
+ function recordUserPresence(c: Context, deps: AccessDeps, subjectId: string): void {
1626
+ if (!deps.userPresence) return;
1627
+ if (c.req.header(OPENGENI_USER_ACTIVITY_HEADER) !== OPENGENI_USER_ACTIVITY_ACTIVE) return;
1628
+ if (presenceRecordedRequests.has(c.req.raw)) return;
1629
+ presenceRecordedRequests.add(c.req.raw);
1630
+ deps.userPresence.touch(subjectId);
1631
+ }
1632
+
783
1633
  async function apiKeyAccessContext(
784
1634
  c: Context,
785
1635
  deps: AccessDeps,
@@ -793,7 +1643,23 @@ async function apiKeyAccessContext(
793
1643
  if (!apiKey) {
794
1644
  return null;
795
1645
  }
1646
+ // Verified at request entry (stampPersonalApiKeyRequest) and resolved as
1647
+ // its owner; reaching here means that step did not run. Never fall back to
1648
+ // a key-shaped principal.
1649
+ if (apiKey.credentialKind === "personal") {
1650
+ throw new HTTPException(401, { message: "personal API key could not be verified" });
1651
+ }
796
1652
  const externalHeader = c.req.header("x-opengeni-external-actor");
1653
+ const service = serviceInitiatorFromHeaders(c.req.raw.headers);
1654
+ if (
1655
+ service &&
1656
+ apiKey.credentialKind !== "organization" &&
1657
+ apiKey.credentialKind !== "workspace"
1658
+ ) {
1659
+ throw new HTTPException(422, {
1660
+ message: "service initiator headers require an organization or workspace API key",
1661
+ });
1662
+ }
797
1663
  if (externalHeader !== undefined) {
798
1664
  if (apiKey.workspaceId !== null || apiKey.credentialKind !== "organization") {
799
1665
  throw new HTTPException(403, { message: "external actors require an organization key" });
@@ -807,9 +1673,15 @@ async function apiKeyAccessContext(
807
1673
  }
808
1674
  let identity: ExternalIdentity;
809
1675
  try {
810
- identity = await ensureExternalIdentity(deps.db, {
811
- accountId: apiKey.accountId,
812
- ...selection.identity,
1676
+ identity = await withAccountRls(deps.db, apiKey.accountId, async (tx) => {
1677
+ // Provisioning locks the live membership after its identity lock.
1678
+ // Claim the lifecycle prefix first so a membership-fenced session
1679
+ // writer can recheck that identity without a lock inversion.
1680
+ await lockExternalWorkspaceMembershipLifecycle(tx, apiKey.accountId);
1681
+ return await ensureExternalIdentity(tx, {
1682
+ accountId: apiKey.accountId,
1683
+ ...selection.identity,
1684
+ });
813
1685
  });
814
1686
  } catch (error) {
815
1687
  if (nestedPostgresSqlState(error) === "42501") {
@@ -841,9 +1713,18 @@ async function apiKeyAccessContext(
841
1713
  externalActorContexts.set(context, {
842
1714
  identity,
843
1715
  keyId: apiKey.id,
844
- permissions: [...apiKey.permissions],
1716
+ permissions:
1717
+ apiKey.permissionMode === "explicit"
1718
+ ? explicitPermissions([...apiKey.permissions])
1719
+ : [...apiKey.permissions],
1720
+ workspaceScope: apiKey.workspaceScope ?? { kind: "all" },
1721
+ permissionMode: apiKey.permissionMode ?? "legacy",
845
1722
  ...(linked ? { linked } : {}),
1723
+ firstUseMembership: selection.mode !== "linked_native" && !linked && !service,
846
1724
  });
1725
+ if (organizationApiKeyAccess(apiKey.permissions) === "developer_setup") {
1726
+ developerSetupApiKeyContexts.add(context);
1727
+ }
847
1728
  return context;
848
1729
  }
849
1730
  const subjectId = `api_key:${apiKey.id}`;
@@ -854,7 +1735,7 @@ async function apiKeyAccessContext(
854
1735
  : apiKey.permissions.filter((permission) =>
855
1736
  accountScopedApiKeyAccountPermissions.has(permission),
856
1737
  );
857
- const context = {
1738
+ const context: AccessContext = {
858
1739
  mode,
859
1740
  subjectId,
860
1741
  subjectLabel: apiKey.name,
@@ -875,17 +1756,27 @@ async function apiKeyAccessContext(
875
1756
  subjectLabel: apiKey.name,
876
1757
  permissions: apiKey.permissions,
877
1758
  principalKind: "api_key",
1759
+ ...service,
878
1760
  },
879
1761
  ]
880
1762
  : [],
881
1763
  defaultAccountId: apiKey.accountId,
882
1764
  defaultWorkspaceId: apiKey.workspaceId,
883
- } satisfies AccessContext;
1765
+ };
1766
+ if (service) apiKeyServiceContexts.set(context, service);
1767
+ if (
1768
+ apiKey.credentialKind === "organization" &&
1769
+ organizationApiKeyAccess(apiKey.permissions) === "developer_setup"
1770
+ ) {
1771
+ developerSetupApiKeyContexts.add(context);
1772
+ }
884
1773
  if (apiKey.workspaceId === null && apiKey.credentialKind === "organization") {
885
1774
  accountScopedApiKeyContexts.set(
886
1775
  context,
887
1776
  Object.freeze({
888
1777
  accountId: apiKey.accountId,
1778
+ workspaceScope: apiKey.workspaceScope ?? { kind: "all" as const },
1779
+ permissionMode: apiKey.permissionMode ?? "legacy",
889
1780
  permissions: Object.freeze(
890
1781
  apiKey.permissions.filter(
891
1782
  (permission) => !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission),
@@ -894,6 +1785,32 @@ async function apiKeyAccessContext(
894
1785
  }),
895
1786
  );
896
1787
  }
1788
+ // Report the exact stamped authority consumed by accessGrantAuthorization,
1789
+ // rather than re-deriving organization-key permissions from a separate rule.
1790
+ // This projection is never an authorization input, and the asUser branch
1791
+ // above deliberately omits it instead of advertising the service's authority.
1792
+ const authority = accountScopedApiKeyWorkspaceAuthority(context);
1793
+ const workspaceGrant =
1794
+ apiKey.credentialKind === "workspace" ? context.workspaceGrants[0] : undefined;
1795
+ const workspacePermissions = authority?.permissions ?? workspaceGrant?.permissions;
1796
+ if (workspacePermissions) {
1797
+ context.credential = {
1798
+ kind: authority ? "organization_api_key" : "workspace_api_key",
1799
+ ...(authority ? { access: organizationApiKeyAccess(apiKey.permissions) } : {}),
1800
+ accountId: apiKey.accountId,
1801
+ workspaceId: apiKey.workspaceId,
1802
+ effectiveWorkspacePermissions: Permission.options.filter(
1803
+ (permission) =>
1804
+ !(developerSetupApiKeyContexts.has(context) && permission === "api_keys:manage") &&
1805
+ !accountScopedApiKeyWorkspaceExcludedPermissions.has(permission) &&
1806
+ hasPermission(workspacePermissions, permission),
1807
+ ),
1808
+ ...(authority ? { policy: apiKey.policy, workspaceScope: authority.workspaceScope } : {}),
1809
+ note: authority
1810
+ ? `These permissions apply to ${authority.workspaceScope.kind === "all" ? "every" : "selected"} shared workspace${authority.workspaceScope.kind === "all" ? "" : "s"} in this organization, not Personal workspaces. workspaceGrants need not enumerate them; accountGrants report organization-level permissions. asUser requests also require user authority.`
1811
+ : "These permissions apply only to the workspace identified by workspaceId; they grant no organization-wide workspace authority.",
1812
+ };
1813
+ }
897
1814
  return context;
898
1815
  }
899
1816
 
@@ -911,7 +1828,11 @@ async function delegatedAccessContext(
911
1828
  if (!payload) {
912
1829
  return null;
913
1830
  }
914
- return {
1831
+ const restricted = payload.credentialRestriction === "developer_setup";
1832
+ const workspacePermissions = restricted
1833
+ ? payload.permissions.filter(isDeveloperSetupDelegatedPermissionAllowed)
1834
+ : payload.permissions;
1835
+ const context: AccessContext = {
915
1836
  mode,
916
1837
  subjectId: payload.subjectId,
917
1838
  ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
@@ -920,7 +1841,7 @@ async function delegatedAccessContext(
920
1841
  accountId: payload.accountId,
921
1842
  subjectId: payload.subjectId,
922
1843
  ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
923
- permissions: payload.permissions,
1844
+ permissions: restricted ? [] : payload.permissions,
924
1845
  },
925
1846
  ],
926
1847
  workspaceGrants: [
@@ -929,7 +1850,7 @@ async function delegatedAccessContext(
929
1850
  accountId: payload.accountId,
930
1851
  subjectId: payload.subjectId,
931
1852
  ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
932
- permissions: payload.permissions,
1853
+ permissions: workspacePermissions,
933
1854
  principalKind: payload.principalKind,
934
1855
  // sessionId is worker-asserted (HMAC-signed token claim), not agent
935
1856
  // controlled; it scopes session-bound MCP tools such as goal management.
@@ -962,6 +1883,8 @@ async function delegatedAccessContext(
962
1883
  defaultAccountId: payload.accountId,
963
1884
  defaultWorkspaceId: payload.workspaceId,
964
1885
  };
1886
+ if (restricted) developerSetupApiKeyContexts.add(context);
1887
+ return context;
965
1888
  }
966
1889
 
967
1890
  function configuredSubject(c: Context): string {