@agent-native/core 0.107.1 → 0.108.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 (257) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/actions.mdx +48 -0
  4. package/corpus/core/docs/content/locales/ar-SA/actions.mdx +38 -0
  5. package/corpus/core/docs/content/locales/de-DE/actions.mdx +38 -0
  6. package/corpus/core/docs/content/locales/es-ES/actions.mdx +38 -0
  7. package/corpus/core/docs/content/locales/fr-FR/actions.mdx +38 -0
  8. package/corpus/core/docs/content/locales/hi-IN/actions.mdx +38 -0
  9. package/corpus/core/docs/content/locales/ja-JP/actions.mdx +38 -0
  10. package/corpus/core/docs/content/locales/ko-KR/actions.mdx +38 -0
  11. package/corpus/core/docs/content/locales/pt-BR/actions.mdx +38 -0
  12. package/corpus/core/docs/content/locales/zh-CN/actions.mdx +38 -0
  13. package/corpus/core/docs/content/locales/zh-TW/actions.mdx +38 -0
  14. package/corpus/core/package.json +5 -1
  15. package/corpus/core/src/a2a/artifact-response.ts +32 -10
  16. package/corpus/core/src/a2a-claims.ts +52 -0
  17. package/corpus/core/src/action.ts +6 -2
  18. package/corpus/core/src/audit/record.ts +6 -0
  19. package/corpus/core/src/client/feature-flags/FeatureFlagsPanel.tsx +519 -0
  20. package/corpus/core/src/client/feature-flags/helpers.ts +77 -0
  21. package/corpus/core/src/client/feature-flags/index.ts +13 -0
  22. package/corpus/core/src/client/feature-flags/types.ts +29 -0
  23. package/corpus/core/src/client/feature-flags/use-feature-flag.ts +26 -0
  24. package/corpus/core/src/client/index.ts +11 -0
  25. package/corpus/core/src/extensions/routes.ts +1 -1
  26. package/corpus/core/src/feature-flags/a2a-action-route.ts +56 -0
  27. package/corpus/core/src/feature-flags/actions/get-feature-flags.ts +24 -0
  28. package/corpus/core/src/feature-flags/actions/list-feature-flags.ts +69 -0
  29. package/corpus/core/src/feature-flags/actions/set-feature-flag.ts +89 -0
  30. package/corpus/core/src/feature-flags/index.ts +21 -0
  31. package/corpus/core/src/feature-flags/permissions.ts +53 -0
  32. package/corpus/core/src/feature-flags/plugin.ts +44 -0
  33. package/corpus/core/src/feature-flags/registry.ts +96 -0
  34. package/corpus/core/src/feature-flags/store.ts +176 -0
  35. package/corpus/core/src/index.ts +15 -0
  36. package/corpus/core/src/integrations/a2a-continuation-processor.ts +107 -3
  37. package/corpus/core/src/integrations/pending-tasks-store.ts +98 -1
  38. package/corpus/core/src/integrations/plugin.ts +129 -4
  39. package/corpus/core/src/integrations/webhook-handler.ts +339 -36
  40. package/corpus/core/src/localization/default-messages.ts +47 -0
  41. package/corpus/core/src/server/action-discovery.ts +12 -0
  42. package/corpus/core/src/server/action-routes.ts +47 -5
  43. package/corpus/core/src/server/agent-chat-plugin.ts +93 -8
  44. package/corpus/core/src/server/index.ts +1 -0
  45. package/corpus/core/src/settings/org-settings.ts +13 -0
  46. package/corpus/core/src/settings/store.ts +57 -0
  47. package/corpus/core/src/templates/default/.agents/skills/feature-flags/SKILL.md +169 -0
  48. package/corpus/core/src/templates/default/AGENTS.md +1 -0
  49. package/corpus/core/src/templates/headless/.agents/skills/feature-flags/SKILL.md +169 -0
  50. package/corpus/core/src/templates/workspace-core/.agents/skills/feature-flags/SKILL.md +169 -0
  51. package/corpus/core/src/templates/workspace-core/AGENTS.md +1 -1
  52. package/corpus/core/src/templates/workspace-root/AGENTS.md +1 -1
  53. package/corpus/core/src/vite/action-types-plugin.ts +12 -0
  54. package/corpus/templates/analytics/.agents/skills/feature-flags/SKILL.md +169 -0
  55. package/corpus/templates/analytics/AGENTS.md +15 -3
  56. package/corpus/templates/analytics/actions/list-workspace-feature-flags.ts +15 -0
  57. package/corpus/templates/analytics/actions/navigate.ts +2 -2
  58. package/corpus/templates/analytics/actions/set-workspace-feature-flag.ts +45 -0
  59. package/corpus/templates/analytics/actions/view-screen.ts +13 -0
  60. package/corpus/templates/analytics/app/components/agents/FeatureFlagsFleetPanel.tsx +243 -0
  61. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +11 -3
  62. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +3 -1
  63. package/corpus/templates/analytics/app/i18n/zh-TW.ts +17 -0
  64. package/corpus/templates/analytics/app/i18n-data.ts +251 -0
  65. package/corpus/templates/analytics/app/pages/Agents.tsx +22 -2
  66. package/corpus/templates/analytics/changelog/2026-07-17-manage-feature-flag-rollouts-across-your-organization-s-apps.md +6 -0
  67. package/corpus/templates/analytics/server/lib/db-admin-connections.ts +13 -5
  68. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +347 -0
  69. package/corpus/templates/assets/.agents/skills/feature-flags/SKILL.md +169 -0
  70. package/corpus/templates/brain/.agents/skills/feature-flags/SKILL.md +169 -0
  71. package/corpus/templates/calendar/.agents/skills/feature-flags/SKILL.md +169 -0
  72. package/corpus/templates/chat/.agents/skills/feature-flags/SKILL.md +169 -0
  73. package/corpus/templates/clips/.agents/skills/feature-flags/SKILL.md +169 -0
  74. package/corpus/templates/clips/server/plugins/feature-flags.ts +11 -0
  75. package/corpus/templates/clips/shared/feature-flags.ts +30 -37
  76. package/corpus/templates/content/.agents/skills/feature-flags/SKILL.md +169 -0
  77. package/corpus/templates/design/.agents/skills/feature-flags/SKILL.md +169 -0
  78. package/corpus/templates/design/AGENTS.md +5 -3
  79. package/corpus/templates/design/actions/add-fusion-screens.ts +4 -6
  80. package/corpus/templates/design/actions/apply-fusion-edits.ts +4 -6
  81. package/corpus/templates/design/actions/create-fusion-app.ts +5 -4
  82. package/corpus/templates/design/actions/deploy-fusion-app.ts +4 -3
  83. package/corpus/templates/design/actions/get-fusion-deploy-status.ts +4 -6
  84. package/corpus/templates/design/actions/list-fusion-edits.ts +4 -3
  85. package/corpus/templates/design/actions/push-fusion-app.ts +4 -6
  86. package/corpus/templates/design/actions/queue-fusion-edit.ts +4 -3
  87. package/corpus/templates/design/actions/send-fusion-message.ts +4 -6
  88. package/corpus/templates/design/actions/sync-fusion-app.ts +4 -3
  89. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +1 -1
  90. package/corpus/templates/design/app/pages/Index.tsx +8 -6
  91. package/corpus/templates/design/server/plugins/feature-flags.ts +5 -0
  92. package/corpus/templates/design/shared/full-app.ts +10 -7
  93. package/corpus/templates/dispatch/.agents/skills/feature-flags/SKILL.md +169 -0
  94. package/corpus/templates/forms/.agents/skills/feature-flags/SKILL.md +169 -0
  95. package/corpus/templates/macros/.agents/skills/feature-flags/SKILL.md +169 -0
  96. package/corpus/templates/mail/.agents/skills/feature-flags/SKILL.md +169 -0
  97. package/corpus/templates/plan/.agents/skills/feature-flags/SKILL.md +169 -0
  98. package/corpus/templates/slides/.agents/skills/feature-flags/SKILL.md +169 -0
  99. package/corpus/templates/tasks/.agents/skills/feature-flags/SKILL.md +169 -0
  100. package/dist/a2a/artifact-response.d.ts +5 -1
  101. package/dist/a2a/artifact-response.d.ts.map +1 -1
  102. package/dist/a2a/artifact-response.js +18 -12
  103. package/dist/a2a/artifact-response.js.map +1 -1
  104. package/dist/a2a-claims.d.ts +10 -0
  105. package/dist/a2a-claims.d.ts.map +1 -0
  106. package/dist/a2a-claims.js +41 -0
  107. package/dist/a2a-claims.js.map +1 -0
  108. package/dist/action.d.ts +6 -2
  109. package/dist/action.d.ts.map +1 -1
  110. package/dist/action.js.map +1 -1
  111. package/dist/audit/record.d.ts +3 -0
  112. package/dist/audit/record.d.ts.map +1 -1
  113. package/dist/audit/record.js +3 -0
  114. package/dist/audit/record.js.map +1 -1
  115. package/dist/client/feature-flags/FeatureFlagsPanel.d.ts +10 -0
  116. package/dist/client/feature-flags/FeatureFlagsPanel.d.ts.map +1 -0
  117. package/dist/client/feature-flags/FeatureFlagsPanel.js +143 -0
  118. package/dist/client/feature-flags/FeatureFlagsPanel.js.map +1 -0
  119. package/dist/client/feature-flags/helpers.d.ts +16 -0
  120. package/dist/client/feature-flags/helpers.d.ts.map +1 -0
  121. package/dist/client/feature-flags/helpers.js +50 -0
  122. package/dist/client/feature-flags/helpers.js.map +1 -0
  123. package/dist/client/feature-flags/index.d.ts +5 -0
  124. package/dist/client/feature-flags/index.d.ts.map +1 -0
  125. package/dist/client/feature-flags/index.js +4 -0
  126. package/dist/client/feature-flags/index.js.map +1 -0
  127. package/dist/client/feature-flags/types.d.ts +27 -0
  128. package/dist/client/feature-flags/types.d.ts.map +1 -0
  129. package/dist/client/feature-flags/types.js +2 -0
  130. package/dist/client/feature-flags/types.js.map +1 -0
  131. package/dist/client/feature-flags/use-feature-flag.d.ts +8 -0
  132. package/dist/client/feature-flags/use-feature-flag.d.ts.map +1 -0
  133. package/dist/client/feature-flags/use-feature-flag.js +15 -0
  134. package/dist/client/feature-flags/use-feature-flag.js.map +1 -0
  135. package/dist/client/index.d.ts +1 -0
  136. package/dist/client/index.d.ts.map +1 -1
  137. package/dist/client/index.js +1 -0
  138. package/dist/client/index.js.map +1 -1
  139. package/dist/collab/routes.d.ts +1 -1
  140. package/dist/collab/struct-routes.d.ts +1 -1
  141. package/dist/extensions/routes.d.ts.map +1 -1
  142. package/dist/extensions/routes.js +1 -1
  143. package/dist/extensions/routes.js.map +1 -1
  144. package/dist/feature-flags/a2a-action-route.d.ts +14 -0
  145. package/dist/feature-flags/a2a-action-route.d.ts.map +1 -0
  146. package/dist/feature-flags/a2a-action-route.js +50 -0
  147. package/dist/feature-flags/a2a-action-route.js.map +1 -0
  148. package/dist/feature-flags/actions/get-feature-flags.d.ts +3 -0
  149. package/dist/feature-flags/actions/get-feature-flags.d.ts.map +1 -0
  150. package/dist/feature-flags/actions/get-feature-flags.js +18 -0
  151. package/dist/feature-flags/actions/get-feature-flags.js.map +1 -0
  152. package/dist/feature-flags/actions/list-feature-flags.d.ts +28 -0
  153. package/dist/feature-flags/actions/list-feature-flags.d.ts.map +1 -0
  154. package/dist/feature-flags/actions/list-feature-flags.js +60 -0
  155. package/dist/feature-flags/actions/list-feature-flags.js.map +1 -0
  156. package/dist/feature-flags/actions/set-feature-flag.d.ts +26 -0
  157. package/dist/feature-flags/actions/set-feature-flag.d.ts.map +1 -0
  158. package/dist/feature-flags/actions/set-feature-flag.js +78 -0
  159. package/dist/feature-flags/actions/set-feature-flag.js.map +1 -0
  160. package/dist/feature-flags/index.d.ts +5 -0
  161. package/dist/feature-flags/index.d.ts.map +1 -0
  162. package/dist/feature-flags/index.js +5 -0
  163. package/dist/feature-flags/index.js.map +1 -0
  164. package/dist/feature-flags/permissions.d.ts +13 -0
  165. package/dist/feature-flags/permissions.d.ts.map +1 -0
  166. package/dist/feature-flags/permissions.js +37 -0
  167. package/dist/feature-flags/permissions.js.map +1 -0
  168. package/dist/feature-flags/plugin.d.ts +17 -0
  169. package/dist/feature-flags/plugin.d.ts.map +1 -0
  170. package/dist/feature-flags/plugin.js +29 -0
  171. package/dist/feature-flags/plugin.js.map +1 -0
  172. package/dist/feature-flags/registry.d.ts +23 -0
  173. package/dist/feature-flags/registry.d.ts.map +1 -0
  174. package/dist/feature-flags/registry.js +59 -0
  175. package/dist/feature-flags/registry.js.map +1 -0
  176. package/dist/feature-flags/store.d.ts +31 -0
  177. package/dist/feature-flags/store.d.ts.map +1 -0
  178. package/dist/feature-flags/store.js +124 -0
  179. package/dist/feature-flags/store.js.map +1 -0
  180. package/dist/index.d.ts +1 -0
  181. package/dist/index.d.ts.map +1 -1
  182. package/dist/index.js +1 -0
  183. package/dist/index.js.map +1 -1
  184. package/dist/integrations/a2a-continuation-processor.js +62 -5
  185. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  186. package/dist/integrations/pending-tasks-store.d.ts +3 -0
  187. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  188. package/dist/integrations/pending-tasks-store.js +75 -1
  189. package/dist/integrations/pending-tasks-store.js.map +1 -1
  190. package/dist/integrations/plugin.d.ts.map +1 -1
  191. package/dist/integrations/plugin.js +81 -5
  192. package/dist/integrations/plugin.js.map +1 -1
  193. package/dist/integrations/webhook-handler.d.ts +23 -2
  194. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  195. package/dist/integrations/webhook-handler.js +257 -35
  196. package/dist/integrations/webhook-handler.js.map +1 -1
  197. package/dist/localization/default-messages.d.ts +42 -0
  198. package/dist/localization/default-messages.d.ts.map +1 -1
  199. package/dist/localization/default-messages.js +42 -0
  200. package/dist/localization/default-messages.js.map +1 -1
  201. package/dist/notifications/routes.d.ts +2 -2
  202. package/dist/observability/routes.d.ts +1 -1
  203. package/dist/progress/routes.d.ts +1 -1
  204. package/dist/server/action-discovery.d.ts.map +1 -1
  205. package/dist/server/action-discovery.js +12 -0
  206. package/dist/server/action-discovery.js.map +1 -1
  207. package/dist/server/action-routes.d.ts +3 -0
  208. package/dist/server/action-routes.d.ts.map +1 -1
  209. package/dist/server/action-routes.js +45 -5
  210. package/dist/server/action-routes.js.map +1 -1
  211. package/dist/server/agent-chat-plugin.d.ts +6 -0
  212. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  213. package/dist/server/agent-chat-plugin.js +71 -6
  214. package/dist/server/agent-chat-plugin.js.map +1 -1
  215. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  216. package/dist/server/index.d.ts +1 -0
  217. package/dist/server/index.d.ts.map +1 -1
  218. package/dist/server/index.js +1 -0
  219. package/dist/server/index.js.map +1 -1
  220. package/dist/settings/org-settings.d.ts +2 -0
  221. package/dist/settings/org-settings.d.ts.map +1 -1
  222. package/dist/settings/org-settings.js +5 -1
  223. package/dist/settings/org-settings.js.map +1 -1
  224. package/dist/settings/store.d.ts +8 -0
  225. package/dist/settings/store.d.ts.map +1 -1
  226. package/dist/settings/store.js +48 -0
  227. package/dist/settings/store.js.map +1 -1
  228. package/dist/templates/chat/.agents/skills/feature-flags/SKILL.md +169 -0
  229. package/dist/templates/default/.agents/skills/feature-flags/SKILL.md +169 -0
  230. package/dist/templates/default/AGENTS.md +1 -0
  231. package/dist/templates/headless/.agents/skills/feature-flags/SKILL.md +169 -0
  232. package/dist/templates/workspace-core/.agents/skills/feature-flags/SKILL.md +169 -0
  233. package/dist/templates/workspace-core/AGENTS.md +1 -1
  234. package/dist/templates/workspace-root/AGENTS.md +1 -1
  235. package/dist/vite/action-types-plugin.d.ts.map +1 -1
  236. package/dist/vite/action-types-plugin.js +12 -0
  237. package/dist/vite/action-types-plugin.js.map +1 -1
  238. package/docs/content/actions.mdx +48 -0
  239. package/docs/content/locales/ar-SA/actions.mdx +38 -0
  240. package/docs/content/locales/de-DE/actions.mdx +38 -0
  241. package/docs/content/locales/es-ES/actions.mdx +38 -0
  242. package/docs/content/locales/fr-FR/actions.mdx +38 -0
  243. package/docs/content/locales/hi-IN/actions.mdx +38 -0
  244. package/docs/content/locales/ja-JP/actions.mdx +38 -0
  245. package/docs/content/locales/ko-KR/actions.mdx +38 -0
  246. package/docs/content/locales/pt-BR/actions.mdx +38 -0
  247. package/docs/content/locales/zh-CN/actions.mdx +38 -0
  248. package/docs/content/locales/zh-TW/actions.mdx +38 -0
  249. package/package.json +5 -1
  250. package/src/templates/chat/.agents/skills/feature-flags/SKILL.md +169 -0
  251. package/src/templates/default/.agents/skills/feature-flags/SKILL.md +169 -0
  252. package/src/templates/default/AGENTS.md +1 -0
  253. package/src/templates/headless/.agents/skills/feature-flags/SKILL.md +169 -0
  254. package/src/templates/workspace-core/.agents/skills/feature-flags/SKILL.md +169 -0
  255. package/src/templates/workspace-core/AGENTS.md +1 -1
  256. package/src/templates/workspace-root/AGENTS.md +1 -1
  257. package/corpus/templates/clips/actions/get-feature-flags.ts +0 -30
@@ -0,0 +1,52 @@
1
+ import * as jose from "jose";
2
+
3
+ import { verifyA2AToken } from "./a2a/server.js";
4
+
5
+ export interface VerifiedA2AClaims {
6
+ email: string;
7
+ orgId: string;
8
+ jti: string;
9
+ issuer?: string;
10
+ scope: string[];
11
+ }
12
+
13
+ /** Typed opt-in claims check; legacy A2A verification remains unchanged. */
14
+ export async function verifyA2ATokenWithClaims(
15
+ token: string,
16
+ event?: any,
17
+ ): Promise<VerifiedA2AClaims | null> {
18
+ const identity = await verifyA2AToken(token, event);
19
+ if (!identity.email) return null;
20
+ try {
21
+ const raw = jose.decodeJwt(token);
22
+ const audiences = Array.isArray(raw.aud)
23
+ ? raw.aud.filter((value): value is string => typeof value === "string")
24
+ : typeof raw.aud === "string"
25
+ ? [raw.aud]
26
+ : [];
27
+ // Legacy A2A callers may omit `aud`, but privileged fleet-management
28
+ // delegation never may. verifyA2AToken already proves a declared audience
29
+ // matches this receiver; this opt-in claims layer makes its presence
30
+ // mandatory before exposing administrative scopes.
31
+ if (audiences.length === 0 || audiences.some((value) => !value.trim()))
32
+ return null;
33
+ const orgId = typeof raw.org_id === "string" ? raw.org_id.trim() : "";
34
+ const jti = typeof raw.jti === "string" ? raw.jti.trim() : "";
35
+ const scopes =
36
+ typeof raw.scope === "string"
37
+ ? raw.scope.split(/\s+/).filter(Boolean)
38
+ : [];
39
+ const issuer = typeof raw.iss === "string" ? raw.iss.trim() : "";
40
+ return orgId && jti
41
+ ? {
42
+ email: identity.email,
43
+ orgId,
44
+ jti,
45
+ ...(issuer ? { issuer } : {}),
46
+ scope: scopes,
47
+ }
48
+ : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
@@ -24,8 +24,8 @@ import type { ActionAuditConfig } from "./audit/types.js";
24
24
  * `callAction` (tagged with the `X-Request-Source` header).
25
25
  * - `"cli"` — `pnpm action <name>` (the CLI runner).
26
26
  * - `"mcp"` — an external agent over the MCP `tools/call` endpoint.
27
- * - `"a2a"` — a direct A2A action dispatch (currently unused: A2A runs through
28
- * the agent loop, so those calls are `"tool"`). Reserved for completeness.
27
+ * - `"a2a"` — a direct, scoped A2A action dispatch. Ordinary A2A agent work
28
+ * still runs through the agent loop and is tagged `"tool"`.
29
29
  */
30
30
  export type ActionCaller = "tool" | "http" | "frontend" | "cli" | "mcp" | "a2a";
31
31
 
@@ -55,6 +55,10 @@ export interface ActionRunContext {
55
55
  orgId?: string | null;
56
56
  /** How this action was invoked. */
57
57
  caller: ActionCaller;
58
+ /** Verified network lineage for direct delegated action calls. */
59
+ networkProtocol?: "a2a" | "mcp" | "provider-api";
60
+ networkId?: string;
61
+ networkPeer?: string;
58
62
  /**
59
63
  * Attachments submitted with the current agent turn (pasted text blocks,
60
64
  * uploaded files, images), exactly as the server received them — with full,
@@ -31,6 +31,9 @@ export interface AuditRunContextLike {
31
31
  orgId?: string | null;
32
32
  threadId?: string;
33
33
  turnId?: string;
34
+ networkProtocol?: "a2a" | "mcp" | "provider-api";
35
+ networkId?: string;
36
+ networkPeer?: string;
34
37
  }
35
38
 
36
39
  export interface RecordActionAuditInput {
@@ -134,6 +137,9 @@ export async function recordActionAudit(
134
137
  // otherwise to the actor (the common self-mutation case).
135
138
  ownerEmail: target?.ownerEmail ?? actorEmail,
136
139
  visibility: target?.visibility ?? "private",
140
+ networkProtocol: ctx?.networkProtocol ?? null,
141
+ networkId: ctx?.networkId ?? null,
142
+ networkPeer: ctx?.networkPeer ?? null,
137
143
  };
138
144
  const lineage = getIntegrationRequestContext()?.lineage;
139
145
  const integration = getIntegrationRequestContext();
@@ -0,0 +1,519 @@
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogAction,
4
+ AlertDialogCancel,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogFooter,
8
+ AlertDialogHeader,
9
+ AlertDialogTitle,
10
+ } from "@agent-native/toolkit/ui/alert-dialog";
11
+ import { Badge } from "@agent-native/toolkit/ui/badge";
12
+ import { Button } from "@agent-native/toolkit/ui/button";
13
+ import {
14
+ DropdownMenu,
15
+ DropdownMenuContent,
16
+ DropdownMenuGroup,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from "@agent-native/toolkit/ui/dropdown-menu";
20
+ import { Input } from "@agent-native/toolkit/ui/input";
21
+ import { Label } from "@agent-native/toolkit/ui/label";
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectGroup,
26
+ SelectItem,
27
+ SelectTrigger,
28
+ SelectValue,
29
+ } from "@agent-native/toolkit/ui/select";
30
+ import { Textarea } from "@agent-native/toolkit/ui/textarea";
31
+ import {
32
+ IconCheck,
33
+ IconDots,
34
+ IconSettings,
35
+ IconLoader2,
36
+ IconUserCheck,
37
+ } from "@tabler/icons-react";
38
+ import { useMemo, useState } from "react";
39
+
40
+ import {
41
+ Dialog,
42
+ DialogContent,
43
+ DialogDescription,
44
+ DialogFooter,
45
+ DialogHeader,
46
+ DialogTitle,
47
+ } from "../components/ui/dialog.js";
48
+ import { useT } from "../i18n.js";
49
+ import {
50
+ normalizeFeatureFlagPercentage,
51
+ normalizeFeatureFlagRules,
52
+ } from "./helpers.js";
53
+ import type {
54
+ FeatureFlagMetadata,
55
+ FeatureFlagRules,
56
+ SetFeatureFlagInput,
57
+ } from "./types.js";
58
+
59
+ function formatActor(actor: FeatureFlagRules["updatedBy"]): string | null {
60
+ if (!actor) return null;
61
+ if (typeof actor === "string") return actor;
62
+ return actor.name ?? actor.email ?? null;
63
+ }
64
+
65
+ function formatWhen(value: number | null | undefined): string | null {
66
+ if (!value) return null;
67
+ const date = new Date(value);
68
+ if (Number.isNaN(date.valueOf())) return null;
69
+ return new Intl.DateTimeFormat(undefined, {
70
+ dateStyle: "medium",
71
+ timeStyle: "short",
72
+ }).format(date);
73
+ }
74
+
75
+ function rolloutLabel(
76
+ rules: FeatureFlagRules,
77
+ t: ReturnType<typeof useT>,
78
+ ): string {
79
+ if (rules.mode === "off") return t("featureFlags.off");
80
+ if (rules.mode === "on") return t("featureFlags.everyone");
81
+ const parts = [
82
+ rules.emails.length
83
+ ? t("featureFlags.emailCount", { count: rules.emails.length })
84
+ : null,
85
+ rules.orgIds.length
86
+ ? t("featureFlags.organizationCount", { count: rules.orgIds.length })
87
+ : null,
88
+ rules.percentage
89
+ ? t("featureFlags.percentageRollout", { count: rules.percentage })
90
+ : null,
91
+ ].filter(Boolean);
92
+ return parts.join(" · ") || t("featureFlags.inherited");
93
+ }
94
+
95
+ function audienceLabel(
96
+ rules: FeatureFlagRules,
97
+ t: ReturnType<typeof useT>,
98
+ ): string {
99
+ if (rules.mode === "off") return t("featureFlags.off");
100
+ if (rules.mode === "on") return t("featureFlags.everyoneAudience");
101
+ return rolloutLabel(rules, t);
102
+ }
103
+
104
+ function modeLabel(rules: FeatureFlagRules, t: ReturnType<typeof useT>) {
105
+ if (rules.mode === "off") return t("featureFlags.off");
106
+ if (rules.mode === "on") return t("featureFlags.everyone");
107
+ return t("featureFlags.targeted");
108
+ }
109
+
110
+ function listText(values: string[]): string {
111
+ return values.join("\n");
112
+ }
113
+
114
+ function parseList(value: string): string[] {
115
+ return [
116
+ ...new Set(
117
+ value
118
+ .split(/[\n,]/)
119
+ .map((entry) => entry.trim())
120
+ .filter(Boolean),
121
+ ),
122
+ ];
123
+ }
124
+
125
+ function TargetingDialog({
126
+ flag,
127
+ open,
128
+ onOpenChange,
129
+ onMutate,
130
+ isPending,
131
+ }: {
132
+ flag: FeatureFlagMetadata;
133
+ open: boolean;
134
+ onOpenChange: (open: boolean) => void;
135
+ onMutate: (input: SetFeatureFlagInput) => void;
136
+ isPending?: boolean;
137
+ }) {
138
+ const t = useT();
139
+ const modeId = `feature-flag-${flag.key}-mode`;
140
+ const emailsId = `feature-flag-${flag.key}-emails`;
141
+ const orgIdsId = `feature-flag-${flag.key}-org-ids`;
142
+ const percentageId = `feature-flag-${flag.key}-percentage`;
143
+ const [mode, setMode] = useState(flag.rules.mode);
144
+ const [emails, setEmails] = useState(() => listText(flag.rules.emails));
145
+ const [orgIds, setOrgIds] = useState(() => listText(flag.rules.orgIds));
146
+ const [percentage, setPercentage] = useState(String(flag.rules.percentage));
147
+
148
+ const save = () => {
149
+ const nextPercentage = normalizeFeatureFlagPercentage(percentage);
150
+ onMutate({
151
+ key: flag.key,
152
+ operation: "replace-rules",
153
+ rules: {
154
+ version: 1,
155
+ mode,
156
+ emails: parseList(emails),
157
+ orgIds: parseList(orgIds),
158
+ percentage: nextPercentage,
159
+ },
160
+ });
161
+ };
162
+
163
+ return (
164
+ <Dialog open={open} onOpenChange={onOpenChange}>
165
+ <DialogContent className="max-w-xl">
166
+ <DialogHeader>
167
+ <DialogTitle>
168
+ {t("featureFlags.targetingTitle", {
169
+ name: flag.displayName ?? flag.key,
170
+ })}
171
+ </DialogTitle>
172
+ <DialogDescription>
173
+ {t("featureFlags.targetingDescription")}
174
+ </DialogDescription>
175
+ </DialogHeader>
176
+ <div className="grid gap-2">
177
+ <Label htmlFor={modeId}>{t("featureFlags.modeLabel")}</Label>
178
+ <Select
179
+ value={mode}
180
+ onValueChange={(value) =>
181
+ setMode(value as FeatureFlagRules["mode"])
182
+ }
183
+ >
184
+ <SelectTrigger id={modeId}>
185
+ <SelectValue />
186
+ </SelectTrigger>
187
+ <SelectContent>
188
+ <SelectGroup>
189
+ <SelectItem value="off">{t("featureFlags.off")}</SelectItem>
190
+ <SelectItem value="rules">
191
+ {t("featureFlags.targeted")}
192
+ </SelectItem>
193
+ <SelectItem value="on">{t("featureFlags.everyone")}</SelectItem>
194
+ </SelectGroup>
195
+ </SelectContent>
196
+ </Select>
197
+ </div>
198
+ {mode === "rules" ? (
199
+ <div className="grid gap-4">
200
+ <div className="grid gap-2">
201
+ <Label htmlFor={emailsId}>{t("featureFlags.emailsLabel")}</Label>
202
+ <Textarea
203
+ id={emailsId}
204
+ className="min-h-20"
205
+ value={emails}
206
+ onChange={(event) => setEmails(event.target.value)}
207
+ placeholder="one@example.com"
208
+ />
209
+ </div>
210
+ <div className="grid gap-2">
211
+ <Label htmlFor={orgIdsId}>{t("featureFlags.orgIdsLabel")}</Label>
212
+ <Textarea
213
+ id={orgIdsId}
214
+ className="min-h-20"
215
+ value={orgIds}
216
+ onChange={(event) => setOrgIds(event.target.value)}
217
+ placeholder="org_123"
218
+ />
219
+ </div>
220
+ <div className="grid gap-2">
221
+ <Label htmlFor={percentageId}>
222
+ {t("featureFlags.percentageLabel")}
223
+ </Label>
224
+ <Input
225
+ id={percentageId}
226
+ type="number"
227
+ min="0"
228
+ max="100"
229
+ step="1"
230
+ value={percentage}
231
+ onChange={(event) => setPercentage(event.target.value)}
232
+ />
233
+ </div>
234
+ <p className="text-xs text-muted-foreground">
235
+ {t("featureFlags.targetingRuleHelp")}
236
+ </p>
237
+ </div>
238
+ ) : null}
239
+ <div className="rounded-md bg-muted/50 px-3 py-2 text-sm">
240
+ {t("featureFlags.targetingSummary", {
241
+ audience: audienceLabel(
242
+ normalizeFeatureFlagRules({
243
+ mode,
244
+ emails: parseList(emails),
245
+ orgIds: parseList(orgIds),
246
+ percentage: normalizeFeatureFlagPercentage(percentage),
247
+ }),
248
+ t,
249
+ ),
250
+ })}
251
+ <span className="ms-2 text-xs text-muted-foreground">
252
+ ·{" "}
253
+ {flag.defaultValue
254
+ ? t("featureFlags.defaultOn")
255
+ : t("featureFlags.defaultOff")}
256
+ </span>
257
+ </div>
258
+ <DialogFooter>
259
+ <Button
260
+ type="button"
261
+ variant="ghost"
262
+ onClick={() => onOpenChange(false)}
263
+ >
264
+ {t("featureFlags.cancel")}
265
+ </Button>
266
+ <Button type="button" disabled={isPending} onClick={save}>
267
+ {isPending ? <IconLoader2 className="animate-spin" /> : null}
268
+ {t("featureFlags.saveRules")}
269
+ </Button>
270
+ </DialogFooter>
271
+ </DialogContent>
272
+ </Dialog>
273
+ );
274
+ }
275
+
276
+ function FeatureFlagRow({
277
+ flag,
278
+ onMutate,
279
+ isPending,
280
+ error,
281
+ }: {
282
+ flag: FeatureFlagMetadata;
283
+ onMutate: (input: SetFeatureFlagInput) => void;
284
+ isPending?: boolean;
285
+ error?: Error | null;
286
+ }) {
287
+ const t = useT();
288
+ const [targetingOpen, setTargetingOpen] = useState(false);
289
+ const [disableOpen, setDisableOpen] = useState(false);
290
+ const actor = formatActor(flag.rules.updatedBy);
291
+ const when = formatWhen(flag.rules.updatedAt);
292
+ const metadata = [actor, when].filter(Boolean).join(" · ");
293
+
294
+ return (
295
+ <article
296
+ id={`feature-flag-${flag.key}`}
297
+ className="grid gap-4 scroll-mt-24 border-b border-border py-5 last:border-b-0 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
298
+ >
299
+ <div className="min-w-0">
300
+ <div className="flex min-w-0 items-center gap-2">
301
+ <h3 className="truncate text-sm font-medium text-foreground">
302
+ {flag.displayName ?? flag.key}
303
+ </h3>
304
+ {flag.displayName && flag.displayName !== flag.key ? (
305
+ <code className="truncate text-xs text-muted-foreground">
306
+ {flag.key}
307
+ </code>
308
+ ) : null}
309
+ <Badge
310
+ variant={flag.rules.mode === "on" ? "secondary" : "outline"}
311
+ className="ms-auto shrink-0"
312
+ >
313
+ {modeLabel(flag.rules, t)}
314
+ </Badge>
315
+ </div>
316
+ {flag.description ? (
317
+ <p className="mt-1 text-sm text-muted-foreground">
318
+ {flag.description}
319
+ </p>
320
+ ) : null}
321
+ <div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
322
+ <span>
323
+ {t("featureFlags.enabledFor")}: {audienceLabel(flag.rules, t)}
324
+ </span>
325
+ {metadata ? (
326
+ <span>{t("featureFlags.lastChanged", { metadata })}</span>
327
+ ) : null}
328
+ </div>
329
+ <div className="mt-2 flex items-center gap-1.5 text-xs font-medium text-foreground">
330
+ {flag.enabledForCurrentUser === true ? (
331
+ <>
332
+ <IconCheck className="size-4 text-emerald-500" />
333
+ {t("featureFlags.youHaveAccess")}
334
+ </>
335
+ ) : flag.enabledForCurrentUser === false ? (
336
+ <>{t("featureFlags.youDoNotHaveAccess")}</>
337
+ ) : (
338
+ <>{t("featureFlags.currentUserUnknown")}</>
339
+ )}
340
+ </div>
341
+ {error ? (
342
+ <p className="mt-3 text-sm text-destructive">
343
+ {t("featureFlags.mutationUnverified", {
344
+ name: flag.displayName ?? flag.key,
345
+ })}
346
+ </p>
347
+ ) : null}
348
+ </div>
349
+ <div className="flex flex-wrap items-center gap-2 md:justify-end">
350
+ <Button
351
+ type="button"
352
+ variant="outline"
353
+ size="sm"
354
+ className="text-xs"
355
+ disabled={isPending || flag.enabledForCurrentUser === true}
356
+ onClick={() =>
357
+ onMutate({
358
+ key: flag.key,
359
+ operation: "enable-for-current-user",
360
+ })
361
+ }
362
+ >
363
+ {flag.enabledForCurrentUser === true ? (
364
+ <IconCheck />
365
+ ) : (
366
+ <IconUserCheck />
367
+ )}
368
+ {flag.enabledForCurrentUser === true
369
+ ? t("featureFlags.enabledForMe")
370
+ : t("featureFlags.enableForMe")}
371
+ </Button>
372
+ <Button
373
+ type="button"
374
+ variant="outline"
375
+ size="sm"
376
+ className="text-xs"
377
+ disabled={isPending}
378
+ onClick={() => setTargetingOpen(true)}
379
+ >
380
+ <IconSettings />
381
+ {t("featureFlags.editTargeting")}
382
+ </Button>
383
+ <DropdownMenu>
384
+ <DropdownMenuTrigger asChild>
385
+ <Button
386
+ type="button"
387
+ variant="ghost"
388
+ size="icon"
389
+ className="size-8 text-muted-foreground"
390
+ disabled={isPending}
391
+ aria-label={t("featureFlags.moreActions", {
392
+ name: flag.displayName ?? flag.key,
393
+ })}
394
+ >
395
+ <IconDots />
396
+ </Button>
397
+ </DropdownMenuTrigger>
398
+ <DropdownMenuContent align="end">
399
+ <DropdownMenuGroup>
400
+ <DropdownMenuItem
401
+ className="text-destructive focus:text-destructive"
402
+ disabled={flag.rules.mode === "off"}
403
+ onSelect={() => setDisableOpen(true)}
404
+ >
405
+ {t("featureFlags.disableForEveryone")}
406
+ </DropdownMenuItem>
407
+ </DropdownMenuGroup>
408
+ </DropdownMenuContent>
409
+ </DropdownMenu>
410
+ </div>
411
+ {targetingOpen ? (
412
+ <TargetingDialog
413
+ flag={flag}
414
+ open
415
+ onOpenChange={setTargetingOpen}
416
+ onMutate={onMutate}
417
+ isPending={isPending}
418
+ />
419
+ ) : null}
420
+ <AlertDialog open={disableOpen} onOpenChange={setDisableOpen}>
421
+ <AlertDialogContent>
422
+ <AlertDialogHeader>
423
+ <AlertDialogTitle>
424
+ {t("featureFlags.disableForEveryoneTitle", {
425
+ name: flag.displayName ?? flag.key,
426
+ })}
427
+ </AlertDialogTitle>
428
+ <AlertDialogDescription>
429
+ {t("featureFlags.disableForEveryoneDescription")}
430
+ </AlertDialogDescription>
431
+ </AlertDialogHeader>
432
+ <AlertDialogFooter>
433
+ <AlertDialogCancel>{t("featureFlags.cancel")}</AlertDialogCancel>
434
+ <AlertDialogAction
435
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
436
+ onClick={() => onMutate({ key: flag.key, operation: "off" })}
437
+ >
438
+ {t("featureFlags.disableForEveryone")}
439
+ </AlertDialogAction>
440
+ </AlertDialogFooter>
441
+ </AlertDialogContent>
442
+ </AlertDialog>
443
+ </article>
444
+ );
445
+ }
446
+
447
+ export function FeatureFlagsEditor({
448
+ flags,
449
+ onMutate,
450
+ isPending,
451
+ error,
452
+ errorFlagKey,
453
+ showHeader = true,
454
+ }: {
455
+ flags: FeatureFlagMetadata[];
456
+ onMutate: (input: SetFeatureFlagInput) => void;
457
+ isPending?: boolean;
458
+ error?: Error | null;
459
+ errorFlagKey?: string | null;
460
+ showHeader?: boolean;
461
+ }) {
462
+ const t = useT();
463
+ const sortedFlags = useMemo(
464
+ () =>
465
+ flags
466
+ .map((flag) => ({
467
+ ...flag,
468
+ rules: normalizeFeatureFlagRules(flag.rules),
469
+ }))
470
+ .sort((left, right) => left.key.localeCompare(right.key)),
471
+ [flags],
472
+ );
473
+
474
+ return (
475
+ <section
476
+ className={
477
+ showHeader ? "mx-auto w-full max-w-2xl" : "mx-auto w-full max-w-4xl"
478
+ }
479
+ aria-labelledby={showHeader ? "feature-flags-title" : undefined}
480
+ aria-label={showHeader ? undefined : t("featureFlags.title")}
481
+ >
482
+ {showHeader ? (
483
+ <header className="border-b border-border pb-5">
484
+ <h2
485
+ id="feature-flags-title"
486
+ className="text-base font-semibold text-foreground"
487
+ >
488
+ {t("featureFlags.title")}
489
+ </h2>
490
+ <p className="mt-1 text-sm text-muted-foreground">
491
+ {t("featureFlags.description")}
492
+ </p>
493
+ </header>
494
+ ) : null}
495
+ {sortedFlags.length ? (
496
+ <div>
497
+ {sortedFlags.map((flag) => (
498
+ <FeatureFlagRow
499
+ key={flag.key}
500
+ flag={flag}
501
+ onMutate={onMutate}
502
+ isPending={isPending}
503
+ error={errorFlagKey === flag.key ? error : null}
504
+ />
505
+ ))}
506
+ </div>
507
+ ) : (
508
+ <p className="py-8 text-sm text-muted-foreground">
509
+ {t("featureFlags.noFlags")}
510
+ </p>
511
+ )}
512
+ {error && !errorFlagKey ? (
513
+ <p className="pt-3 text-sm text-destructive">
514
+ {t("featureFlags.mutationUnverifiedGeneric")}
515
+ </p>
516
+ ) : null}
517
+ </section>
518
+ );
519
+ }
@@ -0,0 +1,77 @@
1
+ import type { FeatureFlagRules } from "./types.js";
2
+
3
+ export type EvaluatedFeatureFlags =
4
+ | Record<string, boolean>
5
+ | { flags?: Record<string, boolean>; values?: Record<string, boolean> };
6
+
7
+ export function evaluatedFeatureFlagValues(
8
+ result: EvaluatedFeatureFlags | undefined,
9
+ ): Record<string, boolean> {
10
+ if (!result) return {};
11
+ const envelope = result as {
12
+ flags?: unknown;
13
+ values?: unknown;
14
+ };
15
+ if (
16
+ envelope.flags &&
17
+ typeof envelope.flags === "object" &&
18
+ !Array.isArray(envelope.flags)
19
+ ) {
20
+ return envelope.flags as Record<string, boolean>;
21
+ }
22
+ if (
23
+ envelope.values &&
24
+ typeof envelope.values === "object" &&
25
+ !Array.isArray(envelope.values)
26
+ ) {
27
+ return envelope.values as Record<string, boolean>;
28
+ }
29
+ return result as Record<string, boolean>;
30
+ }
31
+
32
+ export function featureFlagValue(
33
+ values: Record<string, boolean>,
34
+ key: string,
35
+ ): boolean {
36
+ return values[key] === true;
37
+ }
38
+
39
+ export function normalizeFeatureFlagPercentage(value: unknown): number {
40
+ const percentage = Number(value);
41
+ return Number.isFinite(percentage)
42
+ ? Math.floor(Math.max(0, Math.min(100, percentage)))
43
+ : 0;
44
+ }
45
+
46
+ /**
47
+ * Keep the shared editor safe while a remote app is upgrading or returning an
48
+ * optimistic/transient rule envelope. The fleet contract still validates the
49
+ * authoritative response; this only prevents absent collection fields from
50
+ * crashing the operator UI between refreshes.
51
+ */
52
+ export function normalizeFeatureFlagRules(
53
+ rules: Partial<FeatureFlagRules> | null | undefined,
54
+ ): FeatureFlagRules {
55
+ const mode =
56
+ rules?.mode === "off" || rules?.mode === "on" || rules?.mode === "rules"
57
+ ? rules.mode
58
+ : "rules";
59
+ const percentage = normalizeFeatureFlagPercentage(rules?.percentage);
60
+
61
+ return {
62
+ ...rules,
63
+ version: 1,
64
+ mode,
65
+ emails: Array.isArray(rules?.emails)
66
+ ? rules.emails.filter(
67
+ (value): value is string => typeof value === "string",
68
+ )
69
+ : [],
70
+ orgIds: Array.isArray(rules?.orgIds)
71
+ ? rules.orgIds.filter(
72
+ (value): value is string => typeof value === "string",
73
+ )
74
+ : [],
75
+ percentage,
76
+ };
77
+ }
@@ -0,0 +1,13 @@
1
+ export { FeatureFlagsEditor } from "./FeatureFlagsPanel.js";
2
+ export {
3
+ evaluatedFeatureFlagValues,
4
+ featureFlagValue,
5
+ type EvaluatedFeatureFlags,
6
+ } from "./helpers.js";
7
+ export { useFeatureFlag, useFeatureFlags } from "./use-feature-flag.js";
8
+ export type {
9
+ FeatureFlagActor,
10
+ FeatureFlagMetadata,
11
+ FeatureFlagRules,
12
+ SetFeatureFlagInput,
13
+ } from "./types.js";