aimeat 1.35.0 → 1.37.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 (296) hide show
  1. package/dist/.env.example +31 -0
  2. package/dist/locales/en.json +192 -27
  3. package/dist/locales/fi.json +192 -27
  4. package/dist/public/components/Markdown.js +9 -0
  5. package/dist/public/components/MemoryEmbed.js +151 -0
  6. package/dist/public/components/Mermaid.js +4 -1
  7. package/dist/public/components/NotificationBell.js +45 -19
  8. package/dist/public/css/components/markdown.css +15 -0
  9. package/dist/public/css/views/agents-detail.css +17 -0
  10. package/dist/public/css/views/portfolio.css +51 -0
  11. package/dist/public/css/views/profile.css +31 -0
  12. package/dist/public/js/services/agents.js +9 -0
  13. package/dist/public/js/services/organisms.js +37 -1
  14. package/dist/public/llms-template.txt +278 -0
  15. package/dist/public/spa.html +39 -2
  16. package/dist/public/sw.js +50 -34
  17. package/dist/public/views/my-company.js +13 -1
  18. package/dist/public/views/portfolio.js +267 -44
  19. package/dist/public/views/profile/agents/agent-card.js +4 -0
  20. package/dist/public/views/profile/agents/tab-contracts.js +112 -0
  21. package/dist/public/views/profile/landing-page.js +10 -1
  22. package/dist/public/views/profile/memory-tab.js +6 -2
  23. package/dist/public/views/profile/notifications-tab.js +9 -1
  24. package/dist/public/views/profile/organisms/home.js +23 -3
  25. package/dist/public/views/profile/organisms/participants-panel.js +89 -17
  26. package/dist/public/views/profile/organisms/workspace-apps.js +130 -0
  27. package/dist/public/views/profile/organisms/workspace.js +5 -0
  28. package/dist/public/views/profile/organisms-tab.js +1 -1
  29. package/dist/public/views/secretary.js +13 -0
  30. package/dist/scripts/archive-stale-versions.d.ts +33 -0
  31. package/dist/scripts/archive-stale-versions.d.ts.map +1 -0
  32. package/dist/scripts/archive-stale-versions.js +278 -0
  33. package/dist/scripts/archive-stale-versions.js.map +1 -0
  34. package/dist/src/cli/connect/mcp/tools/agent-management.d.ts +4 -1
  35. package/dist/src/cli/connect/mcp/tools/agent-management.d.ts.map +1 -1
  36. package/dist/src/cli/connect/mcp/tools/agent-management.js +1 -1
  37. package/dist/src/cli/connect/mcp/tools/agent-management.js.map +1 -1
  38. package/dist/src/cli/init-wizard.d.ts.map +1 -1
  39. package/dist/src/cli/init-wizard.js +32 -0
  40. package/dist/src/cli/init-wizard.js.map +1 -1
  41. package/dist/src/config.d.ts +24 -0
  42. package/dist/src/config.d.ts.map +1 -1
  43. package/dist/src/config.js +21 -0
  44. package/dist/src/config.js.map +1 -1
  45. package/dist/src/data/aimeat-iam-package.d.ts +10 -2
  46. package/dist/src/data/aimeat-iam-package.d.ts.map +1 -1
  47. package/dist/src/data/aimeat-iam-package.js +39 -10
  48. package/dist/src/data/aimeat-iam-package.js.map +1 -1
  49. package/dist/src/generated/api-types.d.ts +802 -60
  50. package/dist/src/generated/api-types.d.ts.map +1 -1
  51. package/dist/src/generated/prisma-postgres/edge.js +5 -3
  52. package/dist/src/generated/prisma-postgres/index-browser.js +2 -0
  53. package/dist/src/generated/prisma-postgres/index.d.ts +54 -2
  54. package/dist/src/generated/prisma-postgres/index.js +5 -3
  55. package/dist/src/generated/prisma-postgres/package.json +1 -1
  56. package/dist/src/generated/prisma-postgres/schema.prisma +2 -0
  57. package/dist/src/generated/prisma-postgres/wasm.js +5 -3
  58. package/dist/src/mcp/annotations.d.ts.map +1 -1
  59. package/dist/src/mcp/annotations.js +1 -0
  60. package/dist/src/mcp/annotations.js.map +1 -1
  61. package/dist/src/mcp/catalog/definitions.d.ts +3 -0
  62. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  63. package/dist/src/mcp/catalog/definitions.js +21 -7
  64. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  65. package/dist/src/mcp/catalog/surfaces.js +1 -1
  66. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  67. package/dist/src/mcp/core.js +1 -1
  68. package/dist/src/mcp/core.js.map +1 -1
  69. package/dist/src/mcp/extensions.d.ts.map +1 -1
  70. package/dist/src/mcp/extensions.js +21 -0
  71. package/dist/src/mcp/extensions.js.map +1 -1
  72. package/dist/src/mcp/memory-extended.js +1 -1
  73. package/dist/src/mcp/memory-extended.js.map +1 -1
  74. package/dist/src/mcp/organisms.d.ts +1 -0
  75. package/dist/src/mcp/organisms.d.ts.map +1 -1
  76. package/dist/src/mcp/organisms.js +23 -6
  77. package/dist/src/mcp/organisms.js.map +1 -1
  78. package/dist/src/mcp/workspaces.d.ts +7 -0
  79. package/dist/src/mcp/workspaces.d.ts.map +1 -1
  80. package/dist/src/mcp/workspaces.js +23 -5
  81. package/dist/src/mcp/workspaces.js.map +1 -1
  82. package/dist/src/middleware/subdomain.d.ts +9 -0
  83. package/dist/src/middleware/subdomain.d.ts.map +1 -1
  84. package/dist/src/middleware/subdomain.js +24 -3
  85. package/dist/src/middleware/subdomain.js.map +1 -1
  86. package/dist/src/models/schemas.d.ts +2 -0
  87. package/dist/src/models/schemas.d.ts.map +1 -1
  88. package/dist/src/models/schemas.js +2 -2
  89. package/dist/src/models/schemas.js.map +1 -1
  90. package/dist/src/routes/admin-features.js +1 -1
  91. package/dist/src/routes/admin-features.js.map +1 -1
  92. package/dist/src/routes/agent-onboarding.d.ts.map +1 -1
  93. package/dist/src/routes/agent-onboarding.js +44 -7
  94. package/dist/src/routes/agent-onboarding.js.map +1 -1
  95. package/dist/src/routes/agents.d.ts +8 -0
  96. package/dist/src/routes/agents.d.ts.map +1 -1
  97. package/dist/src/routes/agents.js +91 -5
  98. package/dist/src/routes/agents.js.map +1 -1
  99. package/dist/src/routes/app-grants.d.ts +5 -0
  100. package/dist/src/routes/app-grants.d.ts.map +1 -1
  101. package/dist/src/routes/app-grants.js +52 -20
  102. package/dist/src/routes/app-grants.js.map +1 -1
  103. package/dist/src/routes/boards.d.ts.map +1 -1
  104. package/dist/src/routes/boards.js +4 -1
  105. package/dist/src/routes/boards.js.map +1 -1
  106. package/dist/src/routes/bootstrap.d.ts +2 -0
  107. package/dist/src/routes/bootstrap.d.ts.map +1 -1
  108. package/dist/src/routes/bootstrap.js +5 -0
  109. package/dist/src/routes/bootstrap.js.map +1 -1
  110. package/dist/src/routes/capabilities.d.ts.map +1 -1
  111. package/dist/src/routes/capabilities.js +4 -1
  112. package/dist/src/routes/capabilities.js.map +1 -1
  113. package/dist/src/routes/catalogue.d.ts.map +1 -1
  114. package/dist/src/routes/catalogue.js +5 -4
  115. package/dist/src/routes/catalogue.js.map +1 -1
  116. package/dist/src/routes/extensions.d.ts.map +1 -1
  117. package/dist/src/routes/extensions.js +11 -0
  118. package/dist/src/routes/extensions.js.map +1 -1
  119. package/dist/src/routes/ghii.d.ts.map +1 -1
  120. package/dist/src/routes/ghii.js +65 -17
  121. package/dist/src/routes/ghii.js.map +1 -1
  122. package/dist/src/routes/lib-editor.d.ts +26 -0
  123. package/dist/src/routes/lib-editor.d.ts.map +1 -0
  124. package/dist/src/routes/lib-editor.js +215 -0
  125. package/dist/src/routes/lib-editor.js.map +1 -0
  126. package/dist/src/routes/lib-markdown.d.ts +16 -1
  127. package/dist/src/routes/lib-markdown.d.ts.map +1 -1
  128. package/dist/src/routes/lib-markdown.js +281 -2
  129. package/dist/src/routes/lib-markdown.js.map +1 -1
  130. package/dist/src/routes/lib-organism.d.ts +28 -0
  131. package/dist/src/routes/lib-organism.d.ts.map +1 -0
  132. package/dist/src/routes/lib-organism.js +298 -0
  133. package/dist/src/routes/lib-organism.js.map +1 -0
  134. package/dist/src/routes/lib-portfolio-standalone.d.ts +22 -0
  135. package/dist/src/routes/lib-portfolio-standalone.d.ts.map +1 -0
  136. package/dist/src/routes/lib-portfolio-standalone.js +68 -0
  137. package/dist/src/routes/lib-portfolio-standalone.js.map +1 -0
  138. package/dist/src/routes/libs.d.ts +18 -0
  139. package/dist/src/routes/libs.d.ts.map +1 -1
  140. package/dist/src/routes/libs.js +116 -18
  141. package/dist/src/routes/libs.js.map +1 -1
  142. package/dist/src/routes/memory.d.ts +3 -0
  143. package/dist/src/routes/memory.d.ts.map +1 -1
  144. package/dist/src/routes/memory.js +58 -4
  145. package/dist/src/routes/memory.js.map +1 -1
  146. package/dist/src/routes/notifications.d.ts +5 -1
  147. package/dist/src/routes/notifications.d.ts.map +1 -1
  148. package/dist/src/routes/notifications.js +68 -3
  149. package/dist/src/routes/notifications.js.map +1 -1
  150. package/dist/src/routes/oauth-login.d.ts +26 -21
  151. package/dist/src/routes/oauth-login.d.ts.map +1 -1
  152. package/dist/src/routes/oauth-login.js +257 -229
  153. package/dist/src/routes/oauth-login.js.map +1 -1
  154. package/dist/src/routes/openrouter.d.ts +3 -0
  155. package/dist/src/routes/openrouter.d.ts.map +1 -1
  156. package/dist/src/routes/openrouter.js +10 -5
  157. package/dist/src/routes/openrouter.js.map +1 -1
  158. package/dist/src/routes/organisms.d.ts +6 -0
  159. package/dist/src/routes/organisms.d.ts.map +1 -1
  160. package/dist/src/routes/organisms.js +203 -16
  161. package/dist/src/routes/organisms.js.map +1 -1
  162. package/dist/src/routes/owners.d.ts.map +1 -1
  163. package/dist/src/routes/owners.js +12 -8
  164. package/dist/src/routes/owners.js.map +1 -1
  165. package/dist/src/routes/portfolio.d.ts +41 -1
  166. package/dist/src/routes/portfolio.d.ts.map +1 -1
  167. package/dist/src/routes/portfolio.js +94 -30
  168. package/dist/src/routes/portfolio.js.map +1 -1
  169. package/dist/src/routes/push.d.ts +15 -0
  170. package/dist/src/routes/push.d.ts.map +1 -1
  171. package/dist/src/routes/push.js +16 -1
  172. package/dist/src/routes/push.js.map +1 -1
  173. package/dist/src/routes/site.d.ts +2 -0
  174. package/dist/src/routes/site.d.ts.map +1 -1
  175. package/dist/src/routes/site.js +10 -2
  176. package/dist/src/routes/site.js.map +1 -1
  177. package/dist/src/routes/subdomains.d.ts +5 -0
  178. package/dist/src/routes/subdomains.d.ts.map +1 -1
  179. package/dist/src/routes/subdomains.js +56 -0
  180. package/dist/src/routes/subdomains.js.map +1 -1
  181. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  182. package/dist/src/server-bootstrap/routes-loader.js +10 -15
  183. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  184. package/dist/src/server-bootstrap/static-files.d.ts.map +1 -1
  185. package/dist/src/server-bootstrap/static-files.js +9 -4
  186. package/dist/src/server-bootstrap/static-files.js.map +1 -1
  187. package/dist/src/services/access-guard.d.ts.map +1 -1
  188. package/dist/src/services/access-guard.js +7 -0
  189. package/dist/src/services/access-guard.js.map +1 -1
  190. package/dist/src/services/ai-completion.d.ts +2 -0
  191. package/dist/src/services/ai-completion.d.ts.map +1 -1
  192. package/dist/src/services/ai-completion.js +2 -1
  193. package/dist/src/services/ai-completion.js.map +1 -1
  194. package/dist/src/services/config-schema.d.ts.map +1 -1
  195. package/dist/src/services/config-schema.js +2 -0
  196. package/dist/src/services/config-schema.js.map +1 -1
  197. package/dist/src/services/ecosystem-access.d.ts +24 -8
  198. package/dist/src/services/ecosystem-access.d.ts.map +1 -1
  199. package/dist/src/services/ecosystem-access.js +27 -5
  200. package/dist/src/services/ecosystem-access.js.map +1 -1
  201. package/dist/src/services/generator-autopilot.d.ts +2 -0
  202. package/dist/src/services/generator-autopilot.d.ts.map +1 -1
  203. package/dist/src/services/generator-autopilot.js +3 -1
  204. package/dist/src/services/generator-autopilot.js.map +1 -1
  205. package/dist/src/services/iam/app-commands.d.ts +75 -0
  206. package/dist/src/services/iam/app-commands.d.ts.map +1 -0
  207. package/dist/src/services/iam/app-commands.js +55 -0
  208. package/dist/src/services/iam/app-commands.js.map +1 -0
  209. package/dist/src/services/iam/define-app-iam.d.ts +45 -0
  210. package/dist/src/services/iam/define-app-iam.d.ts.map +1 -0
  211. package/dist/src/services/iam/define-app-iam.js +75 -0
  212. package/dist/src/services/iam/define-app-iam.js.map +1 -0
  213. package/dist/src/services/iam/model.d.ts +113 -0
  214. package/dist/src/services/iam/model.d.ts.map +1 -0
  215. package/dist/src/services/iam/model.js +115 -0
  216. package/dist/src/services/iam/model.js.map +1 -0
  217. package/dist/src/services/mailbox-notification.d.ts +1 -0
  218. package/dist/src/services/mailbox-notification.d.ts.map +1 -1
  219. package/dist/src/services/mailbox-notification.js +3 -0
  220. package/dist/src/services/mailbox-notification.js.map +1 -1
  221. package/dist/src/services/notebook-ai.d.ts +2 -0
  222. package/dist/src/services/notebook-ai.d.ts.map +1 -1
  223. package/dist/src/services/notebook-ai.js +1 -1
  224. package/dist/src/services/notebook-ai.js.map +1 -1
  225. package/dist/src/services/notify.d.ts +11 -1
  226. package/dist/src/services/notify.d.ts.map +1 -1
  227. package/dist/src/services/notify.js +39 -2
  228. package/dist/src/services/notify.js.map +1 -1
  229. package/dist/src/services/oidc-providers.d.ts +94 -0
  230. package/dist/src/services/oidc-providers.d.ts.map +1 -0
  231. package/dist/src/services/oidc-providers.js +181 -0
  232. package/dist/src/services/oidc-providers.js.map +1 -0
  233. package/dist/src/services/organism-privacy.d.ts +46 -0
  234. package/dist/src/services/organism-privacy.d.ts.map +1 -0
  235. package/dist/src/services/organism-privacy.js +42 -0
  236. package/dist/src/services/organism-privacy.js.map +1 -0
  237. package/dist/src/services/organism-template.d.ts.map +1 -1
  238. package/dist/src/services/organism-template.js +3 -1
  239. package/dist/src/services/organism-template.js.map +1 -1
  240. package/dist/src/services/prompt-defaults.d.ts +2 -0
  241. package/dist/src/services/prompt-defaults.d.ts.map +1 -1
  242. package/dist/src/services/prompt-defaults.js +5 -0
  243. package/dist/src/services/prompt-defaults.js.map +1 -1
  244. package/dist/src/services/scheduler.d.ts.map +1 -1
  245. package/dist/src/services/scheduler.js +16 -4
  246. package/dist/src/services/scheduler.js.map +1 -1
  247. package/dist/src/services/skill-bundle/generator.d.ts +2 -0
  248. package/dist/src/services/skill-bundle/generator.d.ts.map +1 -1
  249. package/dist/src/services/skill-bundle/generator.js +3 -1
  250. package/dist/src/services/skill-bundle/generator.js.map +1 -1
  251. package/dist/src/services/workspace-access.d.ts +2 -0
  252. package/dist/src/services/workspace-access.d.ts.map +1 -1
  253. package/dist/src/services/workspace-access.js +20 -8
  254. package/dist/src/services/workspace-access.js.map +1 -1
  255. package/dist/src/services/workspace-engagements.d.ts +65 -0
  256. package/dist/src/services/workspace-engagements.d.ts.map +1 -0
  257. package/dist/src/services/workspace-engagements.js +92 -0
  258. package/dist/src/services/workspace-engagements.js.map +1 -0
  259. package/dist/src/services/workspace-meta.d.ts +9 -2
  260. package/dist/src/services/workspace-meta.d.ts.map +1 -1
  261. package/dist/src/services/workspace-meta.js +22 -2
  262. package/dist/src/services/workspace-meta.js.map +1 -1
  263. package/dist/src/storage/interface.d.ts +10 -2
  264. package/dist/src/storage/interface.d.ts.map +1 -1
  265. package/dist/src/storage/interface.js.map +1 -1
  266. package/dist/src/storage/providers/mongodb/index.d.ts +1 -0
  267. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  268. package/dist/src/storage/providers/mongodb/index.js +29 -2
  269. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  270. package/dist/src/storage/providers/sqlite/index.d.ts +1 -0
  271. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  272. package/dist/src/storage/providers/sqlite/index.js +23 -8
  273. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  274. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  275. package/dist/src/storage/providers/sqlite/schema.js +9 -0
  276. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  277. package/dist/src/storage/repositories/identity.repository.d.ts +2 -0
  278. package/dist/src/storage/repositories/identity.repository.d.ts.map +1 -1
  279. package/dist/src/utils/env-config.d.ts.map +1 -1
  280. package/dist/src/utils/env-config.js +23 -0
  281. package/dist/src/utils/env-config.js.map +1 -1
  282. package/dist/src/utils/env-validator.d.ts.map +1 -1
  283. package/dist/src/utils/env-validator.js +8 -0
  284. package/dist/src/utils/env-validator.js.map +1 -1
  285. package/dist/src/utils/node-descriptor.d.ts.map +1 -1
  286. package/dist/src/utils/node-descriptor.js +1 -0
  287. package/dist/src/utils/node-descriptor.js.map +1 -1
  288. package/dist/static/app-catalog.html +8 -2
  289. package/dist/static/icons/badge-96.png +0 -0
  290. package/dist/static/icons/icon-192.png +0 -0
  291. package/dist/static/icons/icon-512.png +0 -0
  292. package/dist/static/manifest.json +19 -17
  293. package/dist/static/sw.js +13 -11
  294. package/package.json +3 -1
  295. package/prisma/schema.postgres.prisma +2 -0
  296. package/prisma/schema.prisma +2 -0
@@ -3,6 +3,33 @@
3
3
  * @description Portfolio view — builder (select content, generate AI prompt,
4
4
  * upload HTML) and public viewer (sandboxed iframe render).
5
5
  * @version-history
6
+ * v1.5.0 — 2026-07-03 — Portfolio origin surfaces: builder shows the standalone
7
+ * URL (<username>.portfolio.<apex>) + an aimeat-badge toggle (showBadge, saved
8
+ * to portfolio.config); viewer toolbar links to the standalone page. Both come
9
+ * from the server (standalone_url on /v1/portfolio/config and data/:username).
10
+ * v1.4.0 — 2026-07-03 — Viewer bridge: the trusted parent posts login state
11
+ * ('aimeat-portfolio-auth') into the sandboxed portfolio and serves a narrow
12
+ * fetch RPC ('aimeat-portfolio-fetch' → '...-fetch-result') that reads the
13
+ * portfolio owner's records with the VISITOR's session — enabling the new
14
+ * 'members' memory visibility (logged-in-only content, never in the HTML).
15
+ * Requests are allowlisted to data.owner_gaiis; the visitor's JWT never
16
+ * enters the iframe. Prompt documents the protocol + members entries.
17
+ * v1.3.2 — 2026-07-03 — Builder prompt fixes for the sandboxed viewer reality:
18
+ * memory fetch URLs now use the anonymous public route /v1/memory/:gaii/:key
19
+ * (old /v1/memory/:key always 401s without auth) and only for public-visibility
20
+ * records; forbid credentialed fetch() (Origin:null + wildcard CORS rejects it);
21
+ * auth-gate pattern defaults to logged-out placeholder + listens for an
22
+ * 'aimeat-portfolio-auth' postMessage instead of window.AIMEAT (unreachable
23
+ * from the opaque-origin sandbox).
24
+ * v1.3.1 — 2026-07-03 — Stamp the SPA's CSP nonce onto <script> tags before setting
25
+ * srcdoc (viewer + builder preview): about:srcdoc inherits the parent document's
26
+ * CSP (script-src 'self' 'nonce-…'), so the portfolio's nonce-less inline script
27
+ * never executed. The sandbox (opaque origin, no allow-same-origin) remains the
28
+ * security boundary — same trust level as the app-launch 'unsafe-inline' CSP.
29
+ * v1.3.0 — 2026-07-03 — Public viewer renders full-bleed: portfolio iframe fills the
30
+ * viewport below the nav (no more 900px column), slim toolbar with back link + owner
31
+ * name + a native Fullscreen button; drop the dead contentDocument autosize hack
32
+ * (opaque-origin sandbox always threw). Campsite: btn btn-* → btn-*.
6
33
  * v1.2.0 — 2026-06-10 — Workflow round: ①②③ flow banner explains the AI-chat round-trip;
7
34
  * group summaries show selected/total + select-all checkbox; Generate disabled with a hint
8
35
  * when nothing is selected; "Custom" type and custom auth-gate open describe-textareas that
@@ -27,6 +54,24 @@ import { CopyButton } from '/components/CopyButton.js';
27
54
 
28
55
  const NODE_URL = typeof window !== 'undefined' ? window.location.origin : '';
29
56
 
57
+ /* ── CSP nonce stamping for srcdoc iframes ──
58
+ about:srcdoc inherits the SPA document's CSP, whose script-src is
59
+ 'self' + a per-request nonce (no 'unsafe-inline') — so a portfolio's
60
+ inline <script> is blocked unless it carries that nonce. Stamp the
61
+ SPA's own nonce onto the portfolio's script tags before rendering
62
+ (client-side mirror of src/utils/csp-nonce.ts). Isolation does not
63
+ rest on CSP here: the sandbox (allow-scripts only → opaque origin,
64
+ no session/cookies/storage) is the security boundary, matching the
65
+ 'unsafe-inline' CSP the app-launch endpoint grants published apps. */
66
+ function stampCspNonce(htmlStr) {
67
+ let nonce = '';
68
+ for (const s of document.scripts) {
69
+ if (s.nonce) { nonce = s.nonce; break; }
70
+ }
71
+ if (!nonce) return htmlStr;
72
+ return htmlStr.replace(/<script(?=[\s>])/gi, `<script nonce="${nonce}"`);
73
+ }
74
+
30
75
  /* ── Auth helpers ── */
31
76
  function getSession() {
32
77
  const a = window.AIMEAT?.auth;
@@ -127,12 +172,33 @@ The user wants to create a personal portfolio website. Generate a single, self-c
127
172
  }
128
173
  }
129
174
 
130
- // Selected memory entries
175
+ // Selected memory entries — three delivery paths, decided by record visibility:
176
+ // public → plain anonymous fetch from /v1/memory/:gaii/:key
177
+ // members → requested from the hosting viewer over the postMessage bridge
178
+ // (the viewer fetches with the VISITOR's session; anonymous → no data)
179
+ // other → not deliverable to visitors at all; bake preview or placeholder
180
+ const publicMems = selectedMemories.filter(m => m.visibility === 'public');
181
+ const memberMems = selectedMemories.filter(m => m.visibility === 'members');
182
+ const gatedMems = selectedMemories.filter(m => m.visibility !== 'public' && m.visibility !== 'members');
131
183
  if (selectedMemories.length > 0) {
132
184
  prompt += `\n## Memory Entries\n`;
133
- prompt += `These entries can be fetched live from the node API for dynamic portfolio content:\n`;
134
- for (const mem of selectedMemories) {
135
- prompt += `- ${mem.key} (${mem.visibility}) → GET ${url}/v1/memory/${encodeURIComponent(mem.key)}\n`;
185
+ if (publicMems.length > 0) {
186
+ prompt += `These PUBLIC entries can be fetched live (anonymously, no auth) for dynamic portfolio content:\n`;
187
+ for (const mem of publicMems) {
188
+ prompt += `- ${mem.key} → GET ${url}/v1/memory/${encodeURIComponent(mem.gaii || ghii)}/${encodeURIComponent(mem.key)}\n`;
189
+ }
190
+ }
191
+ if (memberMems.length > 0) {
192
+ prompt += `These MEMBERS-ONLY entries are readable by logged-in node members. Do NOT fetch them directly (an anonymous fetch returns 404). Request each from the hosting viewer over the postMessage bridge (see "Viewer Bridge" below) and render the value inside a [data-auth-required] section, keeping a [data-auth-placeholder] fallback:\n`;
193
+ for (const mem of memberMems) {
194
+ prompt += `- key: ${mem.key} · gaii: ${mem.gaii || ghii}\n`;
195
+ }
196
+ }
197
+ if (gatedMems.length > 0) {
198
+ prompt += `These entries are NOT readable by visitors at all (any fetch attempt returns 401/404). Represent each with its preview text baked into the HTML, or a placeholder the user can edit:\n`;
199
+ for (const mem of gatedMems) {
200
+ prompt += `- ${mem.key} (visibility: ${mem.visibility})${mem.preview ? ` — preview: ${mem.preview}` : ''}\n`;
201
+ }
136
202
  }
137
203
  }
138
204
 
@@ -183,35 +249,82 @@ The user wants to create a personal portfolio website. Generate a single, self-c
183
249
  - **CRITICAL — Images and files:** ONLY use URLs listed in the "Selected Images" section above. Do NOT invent, guess, or fabricate any URLs. If a resource (avatar, resume, screenshot, etc.) is not listed above, it does not exist.
184
250
  ${resourceNotes}${sectionNotes}- For missing images: Use inline SVG placeholders, CSS gradients, or emoji — never a broken image URL
185
251
  - For project screenshots: If no screenshot URL is provided for a project, use a styled CSS placeholder card instead of an <img> tag
186
- - If memory entries are selected: Fetch them live with fetch() calls to the node API URLs above
252
+ - If PUBLIC memory entries are listed above: fetch them live with plain \`fetch(url)\` calls to those exact URLs
253
+ - **CRITICAL — fetch() must stay credential-free:** always call \`fetch(url)\` with no options object — never set \`credentials\` ('include'/'same-origin'), and never send an Authorization header. The portfolio runs in an opaque-origin sandbox (the browser sends \`Origin: null\`); the node answers CORS with a wildcard, and browsers reject credentialed requests to wildcard responses. Only the anonymous public URLs listed above are reachable.
187
254
  - Mobile-responsive design (works on phone, tablet, desktop)
188
255
  - Include proper <meta> tags for SEO and social sharing (og:title, og:description, og:image)
189
256
  - **IMPORTANT — CSP compatibility:** The portfolio HTML will be rendered inside a sandboxed iframe. All JavaScript MUST be inside a single \`<script>\` tag at the end of \`<body>\`. Do NOT use inline event handlers (onclick=, onload=, etc.) — use addEventListener instead. Do NOT use external script/CSS CDN links.
190
257
  `;
191
258
 
192
- if (authGates.length > 0) {
259
+ if (authGates.length > 0 || memberMems.length > 0) {
193
260
  prompt += `
194
- ## Auth-Gated Sections Implementation
195
- Sections marked as auth-gated should be hidden by default and only shown when the viewer is
196
- logged in to the AIMEAT node. Use this detection pattern:
261
+ ## Viewer Bridge (auth state + members-only data)
262
+ The portfolio renders inside a sandboxed iframe with an opaque origin: it has NO access to the
263
+ hosting page's session, cookies, or \`window.AIMEAT\` — login state cannot be read directly.
264
+ The hosting AIMEAT viewer bridges over postMessage. Two message flows:
265
+
266
+ 1. Viewer → portfolio: \`{ type: 'aimeat-portfolio-auth', loggedIn: boolean }\` — posted on load
267
+ and whenever the visitor's login state changes.
268
+ 2. Portfolio → viewer: \`{ type: 'aimeat-portfolio-fetch', gaii: string, key: string, id: number }\`
269
+ (id = your own correlation number). The viewer answers with
270
+ \`{ type: 'aimeat-portfolio-fetch-result', id, ok: boolean, gaii, key, value }\` — \`value\` is the
271
+ memory record's value when \`ok\` is true, otherwise null (not logged in / no access).
272
+ The viewer only serves the gaii+key pairs listed in "Memory Entries" above.
273
+
274
+ Implementation pattern — default to logged-out, activate on the viewer's signal:
197
275
 
198
276
  \`\`\`javascript
199
- // Check if the viewer is authenticated on this AIMEAT node
200
- const isLoggedIn = window.AIMEAT?.auth?.hasSession?.() || false;
201
- document.querySelectorAll('[data-auth-required]').forEach(el => {
202
- el.style.display = isLoggedIn ? '' : 'none';
203
- });
204
- // Show placeholder for unauthenticated viewers
205
- document.querySelectorAll('[data-auth-placeholder]').forEach(el => {
206
- el.style.display = isLoggedIn ? 'none' : '';
277
+ function applyAuthState(isLoggedIn) {
278
+ document.querySelectorAll('[data-auth-required]').forEach(el => {
279
+ el.style.display = isLoggedIn ? '' : 'none';
280
+ });
281
+ document.querySelectorAll('[data-auth-placeholder]').forEach(el => {
282
+ el.style.display = isLoggedIn ? 'none' : '';
283
+ });
284
+ }
285
+ applyAuthState(false);
286
+
287
+ // Members-only data: ask the viewer, resolve by correlation id.
288
+ let bridgeSeq = 0;
289
+ const bridgePending = {};
290
+ function fetchMemberData(gaii, key) {
291
+ return new Promise((resolve) => {
292
+ const id = ++bridgeSeq;
293
+ bridgePending[id] = resolve;
294
+ window.parent.postMessage({ type: 'aimeat-portfolio-fetch', gaii, key, id }, '*');
295
+ });
296
+ }
297
+
298
+ window.addEventListener('message', (e) => {
299
+ const d = e.data;
300
+ if (!d) return;
301
+ if (d.type === 'aimeat-portfolio-auth') {
302
+ applyAuthState(!!d.loggedIn);
303
+ // (Re-)request members-only data when the viewer reports a logged-in visitor.
304
+ if (d.loggedIn) loadMemberSections();
305
+ } else if (d.type === 'aimeat-portfolio-fetch-result' && bridgePending[d.id]) {
306
+ bridgePending[d.id](d.ok ? d.value : null);
307
+ delete bridgePending[d.id];
308
+ }
207
309
  });
310
+
311
+ async function loadMemberSections() {
312
+ // Example: const contact = await fetchMemberData('<gaii>', '<key>');
313
+ // if (contact) { render it into the [data-auth-required] section }
314
+ }
208
315
  \`\`\`
209
316
 
210
- Wrap auth-gated content in \`<div data-auth-required>\` and add a placeholder:
317
+ Wrap gated content in \`<div data-auth-required>\` and add a placeholder:
211
318
  \`<div data-auth-placeholder>Log in to see more content</div>\`
212
319
 
213
- NOTE: This is a convenience feature, not a security boundary. The content is in the HTML source.
214
- For truly private data, use the AIMEAT consent system.
320
+ Rules:
321
+ - Show the placeholder until data actually arrives — no spinners waiting on the bridge
322
+ (an older viewer never answers, and the placeholder is the correct fallback).
323
+ - Members-only values arrive ONLY via the bridge — never bake them into the HTML and never
324
+ fetch them directly.
325
+ - Content that is merely wrapped in [data-auth-required] but baked into the HTML is a
326
+ convenience, not a security boundary — it is visible in the HTML source. Keep truly
327
+ private data out of the portfolio entirely.
215
328
  `;
216
329
  }
217
330
 
@@ -265,6 +378,11 @@ function PortfolioBuilder({ session, navigate }) {
265
378
 
266
379
  // Existing portfolio state
267
380
  const [existingConfig, setExistingConfig] = useState(null);
381
+ // Standalone portfolio-origin URL (<username>.portfolio.<apex>) — null when
382
+ // the node has the portfolio origin disabled.
383
+ const [standaloneUrl, setStandaloneUrl] = useState(null);
384
+ // Whether the standalone page shows the aimeat attribution badge (default on).
385
+ const [showBadge, setShowBadge] = useState(true);
268
386
 
269
387
  // Load catalog + existing config
270
388
  useEffect(() => {
@@ -275,7 +393,11 @@ function PortfolioBuilder({ session, navigate }) {
275
393
  ]).then(([catRes, cfgRes]) => {
276
394
  if (catRes.ok !== false && catRes.data) setCatalog(catRes.data);
277
395
  else setErrMsg('Failed to load content catalog');
278
- if (cfgRes.ok !== false && cfgRes.data?.config) setExistingConfig(cfgRes.data.config);
396
+ if (cfgRes.ok !== false && cfgRes.data?.config) {
397
+ setExistingConfig(cfgRes.data.config);
398
+ setShowBadge(cfgRes.data.config.showBadge !== false);
399
+ }
400
+ if (cfgRes.ok !== false && cfgRes.data?.standalone_url) setStandaloneUrl(cfgRes.data.standalone_url);
279
401
  setLoading(false);
280
402
  }).catch(() => {
281
403
  setErrMsg('Network error');
@@ -283,6 +405,14 @@ function PortfolioBuilder({ session, navigate }) {
283
405
  });
284
406
  }, [session]);
285
407
 
408
+ // Persist the badge toggle immediately (it only affects the standalone page).
409
+ const handleBadgeToggle = async (value) => {
410
+ setShowBadge(value);
411
+ const next = { ...(existingConfig || {}), enabled: existingConfig?.enabled || false, showBadge: value, tags: ['portfolio'] };
412
+ setExistingConfig(next);
413
+ await apiPut('/v1/portfolio/config', next);
414
+ };
415
+
286
416
  // Toggle helpers
287
417
  const toggleSet = (setter, value) => {
288
418
  setter(prev => {
@@ -352,6 +482,7 @@ function PortfolioBuilder({ session, navigate }) {
352
482
  selectedBoards: [...selectedBoards],
353
483
  selectedCortex: [...selectedCortex],
354
484
  selectedMemories: [...selectedMemories],
485
+ showBadge,
355
486
  tags: ['portfolio'],
356
487
  });
357
488
  };
@@ -407,6 +538,7 @@ function PortfolioBuilder({ session, navigate }) {
407
538
  authGates: [...authGates],
408
539
  publishedAt: new Date().toISOString(),
409
540
  htmlSizeKb: sizeKb,
541
+ showBadge,
410
542
  tags: ['portfolio'],
411
543
  });
412
544
  setExistingConfig({ ...(existingConfig || {}), enabled: true });
@@ -481,7 +613,10 @@ function PortfolioBuilder({ session, navigate }) {
481
613
  <span class="portfolio-published-dot">●</span>
482
614
  <span>${t('portfolio.builder.enabled')}</span>
483
615
  <a href=${publicUrl} target="_blank" class="portfolio-published-link">${t('portfolio.builder.viewPublic')}</a>
484
- <button class="btn btn-ghost btn-sm" disabled=${uploading} onClick=${handleUnpublish}>
616
+ ${standaloneUrl && html`
617
+ <a href=${standaloneUrl} target="_blank" rel="noopener" class="portfolio-published-link">${tr('portfolio.builder.viewStandalone', 'Own address')} ↗</a>
618
+ `}
619
+ <button class="btn-ghost btn-sm" disabled=${uploading} onClick=${handleUnpublish}>
485
620
  ${tr('portfolio.builder.unpublish', 'Unpublish')}
486
621
  </button>
487
622
  </div>
@@ -684,7 +819,7 @@ function PortfolioBuilder({ session, navigate }) {
684
819
  <h3><span class="portfolio-step-number">4</span> ${t('portfolio.builder.step4Title')}</h3>
685
820
 
686
821
  <div class="portfolio-generate-row">
687
- <button class="btn btn-primary" disabled=${totalSelected === 0} onClick=${handleGenerate}>
822
+ <button class="btn-primary" disabled=${totalSelected === 0} onClick=${handleGenerate}>
688
823
  ${t('portfolio.builder.generateBtn')}
689
824
  </button>
690
825
  ${totalSelected === 0 && html`
@@ -698,7 +833,7 @@ function PortfolioBuilder({ session, navigate }) {
698
833
  <div class="portfolio-prompt-actions">
699
834
  <${CopyButton} text=${generatedPrompt} className="btn-primary"
700
835
  label=${t('portfolio.builder.copyPrompt')} copiedLabel=${t('portfolio.builder.promptCopied')} />
701
- <button class="btn btn-ghost" onClick=${handleDownloadPrompt}>
836
+ <button class="btn-ghost" onClick=${handleDownloadPrompt}>
702
837
  ${t('portfolio.builder.downloadPrompt')}
703
838
  </button>
704
839
  </div>
@@ -723,6 +858,17 @@ function PortfolioBuilder({ session, navigate }) {
723
858
  ${tr('portfolio.builder.publishTarget', 'Will be published at:')}
724
859
  <a href=${publicUrl} target="_blank" class="portfolio-publish-url">${publicUrl}</a>
725
860
  </div>
861
+ ${standaloneUrl && html`
862
+ <div class="portfolio-publish-target">
863
+ ${tr('portfolio.builder.standaloneTarget', 'Also served standalone at:')}
864
+ <a href=${standaloneUrl} target="_blank" rel="noopener" class="portfolio-publish-url">${standaloneUrl}</a>
865
+ </div>
866
+ <div class="portfolio-source-item portfolio-badge-toggle">
867
+ <input type="checkbox" id="pf-show-badge" checked=${showBadge}
868
+ onChange=${(e) => handleBadgeToggle(e.target.checked)} />
869
+ <label for="pf-show-badge">${tr('portfolio.builder.showBadge', 'Show the aimeat.io badge on the standalone page')}</label>
870
+ </div>
871
+ `}
726
872
 
727
873
  <div class="portfolio-upload-zone ${dragover ? 'dragover' : ''}"
728
874
  onClick=${() => fileInputRef.current?.click()}
@@ -750,18 +896,18 @@ function PortfolioBuilder({ session, navigate }) {
750
896
  ></textarea>
751
897
  ${pastedHtml.trim() && html`
752
898
  <div class="portfolio-paste-actions">
753
- <button class="btn btn-primary"
899
+ <button class="btn-primary"
754
900
  onClick=${handlePublishPaste} disabled=${uploading}>
755
901
  ${uploading ? '...' : tr('portfolio.builder.publishPaste', 'Publish pasted HTML')}
756
902
  </button>
757
- <button class="btn btn-ghost" onClick=${() => setShowPreview(p => !p)}>
903
+ <button class="btn-ghost" onClick=${() => setShowPreview(p => !p)}>
758
904
  ${showPreview ? tr('portfolio.builder.closePreview', 'Close preview') : tr('portfolio.builder.previewBtn', 'Preview')}
759
905
  </button>
760
906
  </div>
761
907
  `}
762
908
  ${showPreview && pastedHtml.trim() && html`
763
909
  <!-- Same sandbox as the public viewer: opaque origin, no session access. -->
764
- <iframe class="portfolio-viewer-frame portfolio-preview-frame" srcdoc=${pastedHtml}
910
+ <iframe class="portfolio-viewer-frame portfolio-preview-frame" srcdoc=${stampCspNonce(pastedHtml)}
765
911
  sandbox="allow-scripts"></iframe>
766
912
  `}
767
913
 
@@ -778,7 +924,7 @@ function PortfolioBuilder({ session, navigate }) {
778
924
  </div>
779
925
 
780
926
  <div style="margin-top:2rem;">
781
- <button class="btn btn-ghost" onClick=${() => navigate('/v1/profile')}>
927
+ <button class="btn-ghost" onClick=${() => navigate('/v1/profile')}>
782
928
  ← ${tr('portfolio.builder.backToProfile', 'Profile')}
783
929
  </button>
784
930
  </div>
@@ -792,6 +938,76 @@ function PortfolioViewer({ username, navigate }) {
792
938
  const [data, setData] = useState(null);
793
939
  const [loading, setLoading] = useState(true);
794
940
  const [errMsg, setErrMsg] = useState(null);
941
+ const frameRef = useRef(null);
942
+
943
+ // ── Portfolio bridge ──
944
+ // The sandboxed portfolio (opaque origin) cannot see the visitor's session.
945
+ // This trusted parent brokers two things over postMessage:
946
+ // 1. 'aimeat-portfolio-auth' {loggedIn} — posted on frame load + auth change,
947
+ // so HTML-baked [data-auth-required] sections can toggle (convenience).
948
+ // 2. 'aimeat-portfolio-fetch' {gaii, key, id} → 'aimeat-portfolio-fetch-result'
949
+ // {id, ok, gaii, key, value} — the parent fetches the record with the
950
+ // VISITOR's JWT (which never enters the iframe) via the public read route,
951
+ // so 'members'-visibility records reach logged-in viewers only. Requests
952
+ // are allowlisted to the portfolio owner's identities (data.owner_gaiis)
953
+ // so portfolio JS cannot spend the visitor's token on anything else.
954
+ const postAuthState = (win) => {
955
+ // '*' targetOrigin: an opaque-origin frame is not addressable any other way;
956
+ // the payload is just a boolean.
957
+ win?.postMessage({ type: 'aimeat-portfolio-auth', loggedIn: !!getSession() }, '*');
958
+ };
959
+
960
+ useEffect(() => {
961
+ if (!data?.has_html) return undefined;
962
+ const ownerGaiis = Array.isArray(data.owner_gaiis) ? data.owner_gaiis : [];
963
+
964
+ const onAuthChange = () => postAuthState(frameRef.current?.contentWindow);
965
+
966
+ const onMessage = async (e) => {
967
+ const win = frameRef.current?.contentWindow;
968
+ if (!win || e.source !== win) return; // only our portfolio frame
969
+ const d = e.data;
970
+ if (!d || d.type !== 'aimeat-portfolio-fetch') return;
971
+ const reply = (ok, value) => win.postMessage({
972
+ type: 'aimeat-portfolio-fetch-result',
973
+ id: d.id ?? null, ok, gaii: d.gaii ?? null, key: d.key ?? null, value,
974
+ }, '*');
975
+ if (typeof d.gaii !== 'string' || typeof d.key !== 'string' || !ownerGaiis.includes(d.gaii)) {
976
+ reply(false, null);
977
+ return;
978
+ }
979
+ const s = getSession();
980
+ try {
981
+ const resp = await fetch(`/v1/memory/${encodeURIComponent(d.gaii)}/${encodeURIComponent(d.key)}`,
982
+ s ? { headers: { Authorization: 'Bearer ' + s.jwt } } : undefined);
983
+ const j = await resp.json().catch(() => null);
984
+ const ok = !!(resp.ok && j && j.ok !== false);
985
+ reply(ok, ok ? (j.data?.value ?? null) : null);
986
+ } catch (_) {
987
+ reply(false, null);
988
+ }
989
+ };
990
+
991
+ window.addEventListener('message', onMessage);
992
+ window.addEventListener('aimeat-auth-change', onAuthChange);
993
+ // aimeat-auth-change fires from the login pill's onLogout BEFORE the async
994
+ // auth.logout() has cleared the session, so getSession() still reads the old
995
+ // state at that moment. The auth lib's own 'login'/'logout' events fire only
996
+ // AFTER the state change — subscribe to those too so the frame flips reliably.
997
+ const authApi = window.AIMEAT?.auth;
998
+ if (authApi?.on) {
999
+ authApi.on('login', onAuthChange);
1000
+ authApi.on('logout', onAuthChange);
1001
+ }
1002
+ return () => {
1003
+ window.removeEventListener('message', onMessage);
1004
+ window.removeEventListener('aimeat-auth-change', onAuthChange);
1005
+ if (authApi?.off) {
1006
+ authApi.off('login', onAuthChange);
1007
+ authApi.off('logout', onAuthChange);
1008
+ }
1009
+ };
1010
+ }, [data]);
795
1011
 
796
1012
  useEffect(() => {
797
1013
  fetch(`${NODE_URL}/v1/portfolio/data/${encodeURIComponent(username)}`)
@@ -818,7 +1034,7 @@ function PortfolioViewer({ username, navigate }) {
818
1034
  <div class="portfolio-not-found">
819
1035
  <h2>${t('portfolio.viewer.notFound')}</h2>
820
1036
  <p>${t('portfolio.viewer.notFoundDesc')}</p>
821
- <button class="btn btn-ghost" style="margin-top:1rem;" onClick=${() => navigate('/v1/portal')}>
1037
+ <button class="btn-ghost" style="margin-top:1rem;" onClick=${() => navigate('/v1/portal')}>
822
1038
  ${t('portfolio.viewer.backToPortal')}
823
1039
  </button>
824
1040
  </div>
@@ -826,25 +1042,32 @@ function PortfolioViewer({ username, navigate }) {
826
1042
  `;
827
1043
  }
828
1044
 
829
- // If portfolio HTML exists, render it in a sandboxed iframe
1045
+ // If portfolio HTML exists, render it full-bleed in a sandboxed iframe:
1046
+ // the frame fills the viewport below the nav + toolbar, the portfolio
1047
+ // scrolls inside it. The toolbar offers native fullscreen for a truly
1048
+ // chrome-free view (Esc exits).
830
1049
  if (data.has_html && data.portfolio_html) {
831
1050
  return html`
832
- <div class="portfolio-container portfolio-viewer">
833
- <div style="display:flex; align-items:center; gap:1rem; margin-bottom:1rem; flex-wrap:wrap;">
834
- <button class="btn btn-ghost" onClick=${() => navigate('/v1/portal')}>
1051
+ <div class="portfolio-viewer-full">
1052
+ <div class="portfolio-viewer-bar">
1053
+ <button class="btn-ghost btn-sm" onClick=${() => navigate('/v1/portal')}>
835
1054
  ← ${t('portfolio.viewer.backToPortal')}
836
1055
  </button>
837
- <span style="color:var(--text-dim);">${escHtml(data.display_name || username)}'s portfolio</span>
1056
+ <span class="portfolio-viewer-owner">${escHtml(data.display_name || username)}'s portfolio</span>
1057
+ ${data.standalone_url && html`
1058
+ <a class="btn-ghost btn-sm" href=${data.standalone_url} target="_blank" rel="noopener">
1059
+ ${tr('portfolio.viewer.standalone', 'Own address')} ↗
1060
+ </a>
1061
+ `}
1062
+ <button class="btn-ghost btn-sm portfolio-viewer-fs"
1063
+ title=${tr('portfolio.viewer.fullscreen', 'Fullscreen')}
1064
+ onClick=${() => frameRef.current?.requestFullscreen?.()}>
1065
+ ⛶ ${tr('portfolio.viewer.fullscreen', 'Fullscreen')}
1066
+ </button>
838
1067
  </div>
839
- <iframe class="portfolio-viewer-frame" srcdoc=${data.portfolio_html}
840
- sandbox="allow-scripts"
841
- onLoad=${(e) => {
842
- try {
843
- const h = e.target.contentDocument?.body?.scrollHeight;
844
- if (h) e.target.style.height = h + 40 + 'px';
845
- } catch (_) { /* cross-origin — ignore */ }
846
- }}
847
- ></iframe>
1068
+ <iframe ref=${frameRef} class="portfolio-viewer-frame portfolio-viewer-frame-full"
1069
+ srcdoc=${stampCspNonce(data.portfolio_html)} sandbox="allow-scripts"
1070
+ onLoad=${(e) => postAuthState(e.target.contentWindow)}></iframe>
848
1071
  </div>
849
1072
  `;
850
1073
  }
@@ -855,7 +1078,7 @@ function PortfolioViewer({ username, navigate }) {
855
1078
  <div class="portfolio-not-found">
856
1079
  <h2>${escHtml(data.display_name || username)}</h2>
857
1080
  <p>${data.bio ? escHtml(data.bio) : t('portfolio.viewer.notFoundDesc')}</p>
858
- <button class="btn btn-ghost" style="margin-top:1rem;" onClick=${() => navigate('/v1/portal')}>
1081
+ <button class="btn-ghost" style="margin-top:1rem;" onClick=${() => navigate('/v1/portal')}>
859
1082
  ${t('portfolio.viewer.backToPortal')}
860
1083
  </button>
861
1084
  </div>
@@ -3,6 +3,7 @@
3
3
  * @description Agent card component with collapsed/expanded states,
4
4
  * Two-Zone Header (identity + state-dependent status), and tab bar.
5
5
  * @version-history
6
+ * v1.19.0 -- 2026-07-03 -- Add Contracts sub-tab (tab-contracts.js) — agent-side contract-engagement view.
6
7
  * v1.18.0 -- 2026-06-30 -- Onboarding step labels use tOr() so a missing agentOnboarding.steps.*
7
8
  * key falls back to the server-provided step.title instead of rendering the raw key.
8
9
  * v1.17.0 -- 2026-06-24 -- Secretary P5 (S-A): render a "Specialist · <role>" badge for agents tagged
@@ -71,6 +72,7 @@ import TabIntegration from './tab-integration.js';
71
72
  import TabTasks from './tab-tasks.js';
72
73
  import TabMessages from './tab-messages.js';
73
74
  import TabDataAccess from './tab-data-access.js';
75
+ import TabContracts from './tab-contracts.js';
74
76
  import TabDirectives from './tab-directives.js';
75
77
  import TabAgentConfig from './tab-agent-config.js';
76
78
  import TabActivity from './tab-activity.js';
@@ -94,6 +96,7 @@ const TABS = [
94
96
  { id: 'tasks', key: 'profile.agents.detail.tabs.tasks' },
95
97
  { id: 'messages', key: 'profile.agents.detail.tabs.messages' },
96
98
  { id: 'data-access', key: 'profile.agents.detail.tabs.data_access' },
99
+ { id: 'contracts', key: 'profile.agents.detail.tabs.contracts' },
97
100
  { id: 'quality', key: 'profile.agents.detail.tabs.quality' },
98
101
  { id: 'activity', key: 'profile.agents.detail.tabs.activity' },
99
102
  { id: 'schedules', key: 'profile.agents.detail.tabs.schedules' },
@@ -695,6 +698,7 @@ function renderTabContent(activeTab, agent, onboarding, session, showToast, allA
695
698
  case 'tasks': return html`<${TabTasks} ...${props} openTaskId=${openTaskId} openTaskNonce=${openTaskNonce} />`;
696
699
  case 'messages': return html`<${TabMessages} ...${props} />`;
697
700
  case 'data-access': return html`<${TabDataAccess} ...${props} />`;
701
+ case 'contracts': return html`<${TabContracts} ...${props} />`;
698
702
  case 'directives': return html`<${TabDirectives} ...${props} />`;
699
703
  case 'agent-config': return html`<${TabAgentConfig} ...${props} onDeleteClick=${onDeleteClick} />`;
700
704
  case 'activity': return html`<${TabActivity} ...${props} />`;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @file tab-contracts.js
3
+ * @description Agent detail › Contracts sub-tab. Makes the agent's workspace-contract engagements
4
+ * visible from the AGENT side (previously only the workspace knew, and only as a derived record
5
+ * trace). Shows what the agent OFFERS (its contract.* capability tags) and, first-class, WHERE each
6
+ * contract is currently active or was retired — with a Retire off-switch and a Re-adopt action.
7
+ * @structure TabContracts — offered-contracts row + Active engagements + Retired (history)
8
+ * @usage <${TabContracts} agent=${agent} agentName=${agent.name} showToast=${showToast} />
9
+ * @version-history
10
+ * v1.0.0 — 2026-07-03 — Initial agent Contracts sub-tab (engagement visibility + activate/deactivate).
11
+ */
12
+ import { h } from 'preact';
13
+ import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
14
+ import htm from 'htm';
15
+ import { onLiveUpdate } from '/lib/live-updates.js';
16
+ import { t } from '/js/i18n.js';
17
+ import { getAgentEngagements, contractNamesOf, offersWorkspaceContract, adoptContractTask } from '/js/services/agents.js';
18
+ import { retireEngagement, activateEngagement } from '/js/services/organisms.js';
19
+
20
+ const html = htm.bind(h);
21
+
22
+ const fmtDay = (iso) => { if (!iso) return ''; const d = new Date(iso); return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); };
23
+ const engKey = (e) => `${e.organism_id}:${e.ws}:${e.contract || ''}`;
24
+
25
+ export default function TabContracts({ agent, agentName, showToast }) {
26
+ const [engagements, setEngagements] = useState([]);
27
+ const [loading, setLoading] = useState(true);
28
+ const [busy, setBusy] = useState('');
29
+ const capabilities = contractNamesOf(agent);
30
+ const advertises = offersWorkspaceContract(agent);
31
+
32
+ const load = useCallback(async () => {
33
+ try { setEngagements(await getAgentEngagements(agentName)); }
34
+ finally { setLoading(false); }
35
+ }, [agentName]);
36
+ const loadRef = useRef(load); loadRef.current = load;
37
+ useEffect(() => { load(); return onLiveUpdate(['organisms'], () => loadRef.current()); }, [load]);
38
+
39
+ // Retire = the off-switch: the agent's loop skips this workspace once its engagement is retired.
40
+ const retire = async (e) => {
41
+ setBusy(engKey(e));
42
+ try {
43
+ const r = await retireEngagement(e.organism_id, e.ws, e.agent, e.contract);
44
+ if (r?.ok === false) showToast?.(r?.error?.message || (t('organisms.retireFailed') || 'Could not retire the contract'), true);
45
+ else showToast?.(t('profile.agents.detail.contracts.retiredToast') || 'Retired — the agent will skip this workspace');
46
+ await load();
47
+ } catch (err) { showToast?.(err?.message || 'Error', true); }
48
+ finally { setBusy(''); }
49
+ };
50
+ // Re-adopt = flip back to active + re-queue the provisioning task, mirroring the workspace panel.
51
+ const readopt = async (e) => {
52
+ setBusy(engKey(e));
53
+ try {
54
+ await activateEngagement(e.organism_id, e.ws, e.agent, e.contract).catch(() => {});
55
+ await adoptContractTask(agentName, { organismId: e.organism_id, ws: e.ws, contract: e.contract });
56
+ showToast?.(t('profile.agents.detail.contracts.readoptedToast') || 'Re-adopted — the agent will pick this workspace back up');
57
+ await load();
58
+ } catch (err) { showToast?.(err?.message || 'Error', true); }
59
+ finally { setBusy(''); }
60
+ };
61
+
62
+ if (loading) return html`<div class="pf-agd-empty">${t('profile.loading') || 'Loading…'}</div>`;
63
+
64
+ const active = engagements.filter(e => e.state === 'active');
65
+ const retired = engagements.filter(e => e.state === 'retired');
66
+ const label = (e) => e.contract || (t('organisms.bareContract') || 'contract');
67
+
68
+ return html`
69
+ <div class="pf-agd-contracts">
70
+ <div class="pf-agd-section-title">${t('profile.agents.detail.contracts.offersTitle') || 'Contracts this agent offers'}</div>
71
+ <div class="section-desc">${t('profile.agents.detail.contracts.offersDesc') || 'The workspace contracts this agent advertises. Owners see these when choosing an agent for a workspace.'}</div>
72
+ <div class="agc-caps">
73
+ ${advertises || capabilities.length
74
+ ? (capabilities.length ? capabilities : ['']).map(c => html`<span class="badge badge-info pj-mini" key=${c}>${'📜 '}${c || (t('organisms.bareContract') || 'contract')}</span>`)
75
+ : html`<span class="pf-agd-empty">${t('profile.agents.detail.contracts.noOffers') || 'This agent advertises no workspace contract (add a workspace-contract + contract.<id> tag in Data Access).'}</span>`}
76
+ </div>
77
+
78
+ <div class="pf-agd-section-title agc-mt">${t('profile.agents.detail.contracts.activeTitle') || 'Active in these workspaces'}</div>
79
+ ${active.length ? html`
80
+ <div class="agc-list">
81
+ ${active.map(e => html`
82
+ <div class="agc-row" key=${engKey(e)}>
83
+ <span class="agc-where">
84
+ <span class="badge badge-success pj-mini">${'✓ '}${label(e)}</span>
85
+ <strong>${e.wsName || e.ws}</strong>
86
+ <span class="agc-org">${e.organismName || e.organism_id}</span>
87
+ <span class="agc-since">${(t('profile.agents.detail.contracts.since') || 'since {d}').replace('{d}', fmtDay(e.adoptedAt))}</span>
88
+ </span>
89
+ <button class="btn-outline btn-sm" disabled=${busy === engKey(e)}
90
+ title=${t('organisms.retireHint') || 'Stop this agent from working in this workspace — its loop skips it and the chip becomes “retired”. Its past work stays as history.'}
91
+ onClick=${() => retire(e)}>${busy === engKey(e) ? '…' : (t('organisms.retire') || 'Retire')}</button>
92
+ </div>`)}
93
+ </div>`
94
+ : html`<div class="pf-agd-empty">${t('profile.agents.detail.contracts.noneActive') || 'Not active in any workspace yet. Owners adopt this agent from a workspace’s People panel.'}</div>`}
95
+
96
+ ${retired.length ? html`
97
+ <div class="pf-agd-section-title agc-mt">${t('profile.agents.detail.contracts.retiredTitle') || 'Retired (history)'}</div>
98
+ <div class="agc-list">
99
+ ${retired.map(e => html`
100
+ <div class="agc-row agc-row--retired" key=${engKey(e)}>
101
+ <span class="agc-where">
102
+ <span class="badge badge-muted pj-mini">${label(e)}</span>
103
+ <strong>${e.wsName || e.ws}</strong>
104
+ <span class="agc-org">${e.organismName || e.organism_id}</span>
105
+ <span class="agc-since">${(t('profile.agents.detail.contracts.until') || 'used here until {d}').replace('{d}', fmtDay(e.retiredAt))}</span>
106
+ </span>
107
+ <button class="btn-ghost btn-sm" disabled=${busy === engKey(e)}
108
+ onClick=${() => readopt(e)}>${busy === engKey(e) ? '…' : (t('organisms.reAdopt') || 'Re-adopt')}</button>
109
+ </div>`)}
110
+ </div>` : null}
111
+ </div>`;
112
+ }
@@ -197,7 +197,7 @@ export function tierLevel(tier) {
197
197
  function EditProfileModal({ session, onClose, onSaved, onChangePassword }) {
198
198
  const [loading, setLoading] = useState(true);
199
199
  const [saving, setSaving] = useState(false);
200
- const [fields, setFields] = useState({ display_name: '', bio: '', avatar: '', locale: 'en' });
200
+ const [fields, setFields] = useState({ display_name: '', bio: '', avatar: '', locale: 'en', directory_listed: false });
201
201
  const [currentEmail, setCurrentEmail] = useState('');
202
202
 
203
203
  useEffect(() => {
@@ -213,6 +213,7 @@ function EditProfileModal({ session, onClose, onSaved, onChangePassword }) {
213
213
  bio: d.bio || '',
214
214
  avatar: d.avatar || '',
215
215
  locale: d.locale || 'en',
216
+ directory_listed: d.directory_listed === true,
216
217
  });
217
218
  setCurrentEmail(d.notification_email || '');
218
219
  }
@@ -299,6 +300,14 @@ function EditProfileModal({ session, onClose, onSaved, onChangePassword }) {
299
300
  <a href="#" class="pf-edit-link" onClick=${(e) => { e.preventDefault(); onClose(); window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'email' } })); }}>
300
301
  ${t('profile.landing.editEmailLink') || 'Change in the Email tab →'}</a>
301
302
  </div>
303
+ <label class="pf-edit-check">
304
+ <input type="checkbox" checked=${fields.directory_listed}
305
+ onChange=${(e) => set('directory_listed', e.target.checked)} />
306
+ <span>
307
+ <span class="pf-edit-check-title">${t('profile.landing.editDirectoryListed') || 'List me in the member directory'}</span>
308
+ <span class="pf-edit-hint">${t('profile.landing.editDirectoryHint') || 'Off by default. When on, other signed-in members can find you (name, bio, avatar) in the directory. Anonymous visitors never see it.'}</span>
309
+ </span>
310
+ </label>
302
311
  </div>
303
312
  <div class="pf-edit-footer">
304
313
  <a href="#" class="pf-edit-link pf-edit-footer-left" onClick=${(e) => { e.preventDefault(); onChangePassword?.(); }}>