aimeat 1.26.0 → 1.27.1

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 (307) hide show
  1. package/README.md +7 -0
  2. package/dist/.env.example +19 -0
  3. package/dist/locales/en.json +507 -288
  4. package/dist/locales/fi.json +476 -257
  5. package/dist/public/css/components/app-sandbox.css +62 -0
  6. package/dist/public/css/views/app-grant.css +83 -0
  7. package/dist/public/css/views/inbox.css +30 -0
  8. package/dist/public/css/views/landing.css +16 -2
  9. package/dist/public/css/views/living.css +187 -0
  10. package/dist/public/css/views/notebook.css +89 -0
  11. package/dist/public/css/views/profile.css +42 -85
  12. package/dist/public/js/app-sandbox.js +42 -0
  13. package/dist/public/js/services/generator-prompts-base.js +40 -3
  14. package/dist/public/js/services/living.js +506 -0
  15. package/dist/public/js/services/notebook-plan.js +211 -0
  16. package/dist/public/js/services/notebook.js +149 -0
  17. package/dist/public/js/services/organisms.js +10 -2
  18. package/dist/public/js/services/tracked-responses.js +79 -0
  19. package/dist/public/js/utils.js +15 -0
  20. package/dist/public/spa.html +20 -1
  21. package/dist/public/types/aimeat-globals.d.ts +4 -0
  22. package/dist/public/views/app-grant.js +210 -0
  23. package/dist/public/views/business.js +8 -1
  24. package/dist/public/views/how-it-works.js +5 -1
  25. package/dist/public/views/landing-node-totals.js +71 -0
  26. package/dist/public/views/landing.js +88 -79
  27. package/dist/public/views/profile/access-tab.js +75 -0
  28. package/dist/public/views/profile/agents-tab.js +3 -2
  29. package/dist/public/views/profile/apps-tab.js +17 -0
  30. package/dist/public/views/profile/inbox-tab.js +204 -7
  31. package/dist/public/views/profile/landing-page.js +255 -163
  32. package/dist/public/views/profile/living-tab.js +480 -0
  33. package/dist/public/views/profile/notebook-card.js +489 -0
  34. package/dist/public/views/profile/notebook-helpers.js +48 -0
  35. package/dist/public/views/profile/notebook-tab.js +40 -260
  36. package/dist/public/views/profile.js +2 -0
  37. package/dist/scripts/reassign-apps.d.ts +2 -0
  38. package/dist/scripts/reassign-apps.d.ts.map +1 -0
  39. package/dist/scripts/reassign-apps.js +99 -0
  40. package/dist/scripts/reassign-apps.js.map +1 -0
  41. package/dist/scripts/screenshot-worker.d.ts +2 -0
  42. package/dist/scripts/screenshot-worker.d.ts.map +1 -0
  43. package/dist/scripts/screenshot-worker.js +14 -0
  44. package/dist/scripts/screenshot-worker.js.map +1 -0
  45. package/dist/src/auth/jwt.d.ts +2 -0
  46. package/dist/src/auth/jwt.d.ts.map +1 -1
  47. package/dist/src/auth/jwt.js +2 -0
  48. package/dist/src/auth/jwt.js.map +1 -1
  49. package/dist/src/auth/middleware.d.ts.map +1 -1
  50. package/dist/src/auth/middleware.js +6 -2
  51. package/dist/src/auth/middleware.js.map +1 -1
  52. package/dist/src/cli/init-wizard.d.ts +7 -3
  53. package/dist/src/cli/init-wizard.d.ts.map +1 -1
  54. package/dist/src/cli/init-wizard.js +50 -5
  55. package/dist/src/cli/init-wizard.js.map +1 -1
  56. package/dist/src/cli/screenshot-worker.d.ts +2 -0
  57. package/dist/src/cli/screenshot-worker.d.ts.map +1 -0
  58. package/dist/src/cli/screenshot-worker.js +156 -0
  59. package/dist/src/cli/screenshot-worker.js.map +1 -0
  60. package/dist/src/config.d.ts +23 -0
  61. package/dist/src/config.d.ts.map +1 -1
  62. package/dist/src/config.js +28 -0
  63. package/dist/src/config.js.map +1 -1
  64. package/dist/src/generated/api-types.d.ts +2756 -1285
  65. package/dist/src/generated/api-types.d.ts.map +1 -1
  66. package/dist/src/generated/prisma-postgres/edge.js +21 -3
  67. package/dist/src/generated/prisma-postgres/index-browser.js +18 -0
  68. package/dist/src/generated/prisma-postgres/index.d.ts +1648 -155
  69. package/dist/src/generated/prisma-postgres/index.js +21 -3
  70. package/dist/src/generated/prisma-postgres/package.json +1 -1
  71. package/dist/src/generated/prisma-postgres/schema.prisma +21 -0
  72. package/dist/src/generated/prisma-postgres/wasm.js +21 -3
  73. package/dist/src/index.js +8 -0
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/mcp/apps.d.ts +2 -0
  76. package/dist/src/mcp/apps.d.ts.map +1 -1
  77. package/dist/src/mcp/apps.js +32 -2
  78. package/dist/src/mcp/apps.js.map +1 -1
  79. package/dist/src/mcp/extensions.d.ts +1 -0
  80. package/dist/src/mcp/extensions.d.ts.map +1 -1
  81. package/dist/src/mcp/extensions.js +10 -1
  82. package/dist/src/mcp/extensions.js.map +1 -1
  83. package/dist/src/mcp/workspaces.d.ts.map +1 -1
  84. package/dist/src/mcp/workspaces.js +4 -1
  85. package/dist/src/mcp/workspaces.js.map +1 -1
  86. package/dist/src/middleware/rate-limit.d.ts +10 -0
  87. package/dist/src/middleware/rate-limit.d.ts.map +1 -1
  88. package/dist/src/middleware/rate-limit.js +24 -4
  89. package/dist/src/middleware/rate-limit.js.map +1 -1
  90. package/dist/src/middleware/subdomain.d.ts +15 -3
  91. package/dist/src/middleware/subdomain.d.ts.map +1 -1
  92. package/dist/src/middleware/subdomain.js +35 -12
  93. package/dist/src/middleware/subdomain.js.map +1 -1
  94. package/dist/src/routes/admin.js +4 -2
  95. package/dist/src/routes/admin.js.map +1 -1
  96. package/dist/src/routes/agent-webhook.d.ts.map +1 -1
  97. package/dist/src/routes/agent-webhook.js +3 -2
  98. package/dist/src/routes/agent-webhook.js.map +1 -1
  99. package/dist/src/routes/app-grants.d.ts +35 -0
  100. package/dist/src/routes/app-grants.d.ts.map +1 -0
  101. package/dist/src/routes/app-grants.js +370 -0
  102. package/dist/src/routes/app-grants.js.map +1 -0
  103. package/dist/src/routes/apps.d.ts +18 -0
  104. package/dist/src/routes/apps.d.ts.map +1 -1
  105. package/dist/src/routes/apps.js +232 -13
  106. package/dist/src/routes/apps.js.map +1 -1
  107. package/dist/src/routes/extensions.d.ts +2 -0
  108. package/dist/src/routes/extensions.d.ts.map +1 -1
  109. package/dist/src/routes/extensions.js +18 -9
  110. package/dist/src/routes/extensions.js.map +1 -1
  111. package/dist/src/routes/foundry.d.ts.map +1 -1
  112. package/dist/src/routes/foundry.js +13 -1
  113. package/dist/src/routes/foundry.js.map +1 -1
  114. package/dist/src/routes/generator.d.ts.map +1 -1
  115. package/dist/src/routes/generator.js +13 -1
  116. package/dist/src/routes/generator.js.map +1 -1
  117. package/dist/src/routes/ghii.d.ts.map +1 -1
  118. package/dist/src/routes/ghii.js +20 -10
  119. package/dist/src/routes/ghii.js.map +1 -1
  120. package/dist/src/routes/librarian.d.ts.map +1 -1
  121. package/dist/src/routes/librarian.js +64 -2
  122. package/dist/src/routes/librarian.js.map +1 -1
  123. package/dist/src/routes/libs.d.ts +15 -0
  124. package/dist/src/routes/libs.d.ts.map +1 -1
  125. package/dist/src/routes/libs.js +301 -2
  126. package/dist/src/routes/libs.js.map +1 -1
  127. package/dist/src/routes/living.d.ts +18 -0
  128. package/dist/src/routes/living.d.ts.map +1 -0
  129. package/dist/src/routes/living.js +63 -0
  130. package/dist/src/routes/living.js.map +1 -0
  131. package/dist/src/routes/memory.d.ts.map +1 -1
  132. package/dist/src/routes/memory.js +4 -1
  133. package/dist/src/routes/memory.js.map +1 -1
  134. package/dist/src/routes/messages.d.ts +2 -0
  135. package/dist/src/routes/messages.d.ts.map +1 -1
  136. package/dist/src/routes/messages.js +16 -87
  137. package/dist/src/routes/messages.js.map +1 -1
  138. package/dist/src/routes/oauth-login.d.ts +22 -0
  139. package/dist/src/routes/oauth-login.d.ts.map +1 -0
  140. package/dist/src/routes/oauth-login.js +219 -0
  141. package/dist/src/routes/oauth-login.js.map +1 -0
  142. package/dist/src/routes/organisms.d.ts.map +1 -1
  143. package/dist/src/routes/organisms.js +4 -1
  144. package/dist/src/routes/organisms.js.map +1 -1
  145. package/dist/src/routes/portal.d.ts +3 -0
  146. package/dist/src/routes/portal.d.ts.map +1 -1
  147. package/dist/src/routes/portal.js +20 -12
  148. package/dist/src/routes/portal.js.map +1 -1
  149. package/dist/src/routes/public-stats.d.ts +3 -0
  150. package/dist/src/routes/public-stats.d.ts.map +1 -1
  151. package/dist/src/routes/public-stats.js +48 -0
  152. package/dist/src/routes/public-stats.js.map +1 -1
  153. package/dist/src/routes/storage-files.js +10 -10
  154. package/dist/src/routes/storage-files.js.map +1 -1
  155. package/dist/src/routes/subdomains.d.ts +33 -0
  156. package/dist/src/routes/subdomains.d.ts.map +1 -1
  157. package/dist/src/routes/subdomains.js +182 -9
  158. package/dist/src/routes/subdomains.js.map +1 -1
  159. package/dist/src/routes/tracked-responses.d.ts +27 -0
  160. package/dist/src/routes/tracked-responses.d.ts.map +1 -0
  161. package/dist/src/routes/tracked-responses.js +160 -0
  162. package/dist/src/routes/tracked-responses.js.map +1 -0
  163. package/dist/src/routes/upload.js +8 -0
  164. package/dist/src/routes/upload.js.map +1 -1
  165. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  166. package/dist/src/server-bootstrap/routes-loader.js +33 -0
  167. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  168. package/dist/src/server-bootstrap/service-init.d.ts.map +1 -1
  169. package/dist/src/server-bootstrap/service-init.js +4 -0
  170. package/dist/src/server-bootstrap/service-init.js.map +1 -1
  171. package/dist/src/server-bootstrap/static-files.d.ts +8 -0
  172. package/dist/src/server-bootstrap/static-files.d.ts.map +1 -1
  173. package/dist/src/server-bootstrap/static-files.js +52 -3
  174. package/dist/src/server-bootstrap/static-files.js.map +1 -1
  175. package/dist/src/server.d.ts.map +1 -1
  176. package/dist/src/server.js +5 -0
  177. package/dist/src/server.js.map +1 -1
  178. package/dist/src/services/core-jobs.d.ts.map +1 -1
  179. package/dist/src/services/core-jobs.js +5 -0
  180. package/dist/src/services/core-jobs.js.map +1 -1
  181. package/dist/src/services/event-bus.d.ts +10 -0
  182. package/dist/src/services/event-bus.d.ts.map +1 -1
  183. package/dist/src/services/event-bus.js +9 -0
  184. package/dist/src/services/event-bus.js.map +1 -1
  185. package/dist/src/services/generator-prompts/resolvers.d.ts +2 -0
  186. package/dist/src/services/generator-prompts/resolvers.d.ts.map +1 -1
  187. package/dist/src/services/generator-prompts/resolvers.js +22 -3
  188. package/dist/src/services/generator-prompts/resolvers.js.map +1 -1
  189. package/dist/src/services/hooks.js +2 -2
  190. package/dist/src/services/hooks.js.map +1 -1
  191. package/dist/src/services/job-seeding.d.ts.map +1 -1
  192. package/dist/src/services/job-seeding.js +2 -0
  193. package/dist/src/services/job-seeding.js.map +1 -1
  194. package/dist/src/services/living-author-prompt.d.ts +14 -0
  195. package/dist/src/services/living-author-prompt.d.ts.map +1 -0
  196. package/dist/src/services/living-author-prompt.js +47 -0
  197. package/dist/src/services/living-author-prompt.js.map +1 -0
  198. package/dist/src/services/living-author.d.ts +55 -0
  199. package/dist/src/services/living-author.d.ts.map +1 -0
  200. package/dist/src/services/living-author.js +91 -0
  201. package/dist/src/services/living-author.js.map +1 -0
  202. package/dist/src/services/living-pulse.d.ts +29 -0
  203. package/dist/src/services/living-pulse.d.ts.map +1 -0
  204. package/dist/src/services/living-pulse.js +363 -0
  205. package/dist/src/services/living-pulse.js.map +1 -0
  206. package/dist/src/services/message-send.d.ts +24 -0
  207. package/dist/src/services/message-send.d.ts.map +1 -0
  208. package/dist/src/services/message-send.js +114 -0
  209. package/dist/src/services/message-send.js.map +1 -0
  210. package/dist/src/services/notebook-ai.d.ts +36 -0
  211. package/dist/src/services/notebook-ai.d.ts.map +1 -0
  212. package/dist/src/services/notebook-ai.js +51 -0
  213. package/dist/src/services/notebook-ai.js.map +1 -0
  214. package/dist/src/services/notebook-classify.d.ts +32 -5
  215. package/dist/src/services/notebook-classify.d.ts.map +1 -1
  216. package/dist/src/services/notebook-classify.js +59 -43
  217. package/dist/src/services/notebook-classify.js.map +1 -1
  218. package/dist/src/services/notebook-distribute-prompt.d.ts +16 -0
  219. package/dist/src/services/notebook-distribute-prompt.d.ts.map +1 -0
  220. package/dist/src/services/notebook-distribute-prompt.js +40 -0
  221. package/dist/src/services/notebook-distribute-prompt.js.map +1 -0
  222. package/dist/src/services/notebook-plan-prompt.d.ts +19 -0
  223. package/dist/src/services/notebook-plan-prompt.d.ts.map +1 -0
  224. package/dist/src/services/notebook-plan-prompt.js +54 -0
  225. package/dist/src/services/notebook-plan-prompt.js.map +1 -0
  226. package/dist/src/services/notebook-plan.d.ts +70 -0
  227. package/dist/src/services/notebook-plan.d.ts.map +1 -0
  228. package/dist/src/services/notebook-plan.js +118 -0
  229. package/dist/src/services/notebook-plan.js.map +1 -0
  230. package/dist/src/services/oidc-client.d.ts +3 -0
  231. package/dist/src/services/oidc-client.d.ts.map +1 -1
  232. package/dist/src/services/oidc-client.js +17 -2
  233. package/dist/src/services/oidc-client.js.map +1 -1
  234. package/dist/src/services/prompt-defaults.d.ts.map +1 -1
  235. package/dist/src/services/prompt-defaults.js +30 -0
  236. package/dist/src/services/prompt-defaults.js.map +1 -1
  237. package/dist/src/services/quota.d.ts +14 -1
  238. package/dist/src/services/quota.d.ts.map +1 -1
  239. package/dist/src/services/quota.js +28 -1
  240. package/dist/src/services/quota.js.map +1 -1
  241. package/dist/src/services/scheduler.d.ts +5 -0
  242. package/dist/src/services/scheduler.d.ts.map +1 -1
  243. package/dist/src/services/scheduler.js +4 -0
  244. package/dist/src/services/scheduler.js.map +1 -1
  245. package/dist/src/services/screenshot-capture.d.ts +28 -0
  246. package/dist/src/services/screenshot-capture.d.ts.map +1 -0
  247. package/dist/src/services/screenshot-capture.js +154 -0
  248. package/dist/src/services/screenshot-capture.js.map +1 -0
  249. package/dist/src/services/track-registry.d.ts +29 -0
  250. package/dist/src/services/track-registry.d.ts.map +1 -0
  251. package/dist/src/services/track-registry.js +61 -0
  252. package/dist/src/services/track-registry.js.map +1 -0
  253. package/dist/src/services/tracked-response.d.ts +98 -0
  254. package/dist/src/services/tracked-response.d.ts.map +1 -0
  255. package/dist/src/services/tracked-response.js +304 -0
  256. package/dist/src/services/tracked-response.js.map +1 -0
  257. package/dist/src/services/webhook-dispatcher.d.ts.map +1 -1
  258. package/dist/src/services/webhook-dispatcher.js +3 -2
  259. package/dist/src/services/webhook-dispatcher.js.map +1 -1
  260. package/dist/src/storage/interface.d.ts +19 -2
  261. package/dist/src/storage/interface.d.ts.map +1 -1
  262. package/dist/src/storage/interface.js.map +1 -1
  263. package/dist/src/storage/providers/mongodb/index.d.ts +16 -1
  264. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  265. package/dist/src/storage/providers/mongodb/index.js +123 -0
  266. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  267. package/dist/src/storage/providers/sqlite/index.d.ts +10 -1
  268. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  269. package/dist/src/storage/providers/sqlite/index.js +119 -6
  270. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  271. package/dist/src/storage/providers/sqlite/repos/identity.d.ts +1 -0
  272. package/dist/src/storage/providers/sqlite/repos/identity.d.ts.map +1 -1
  273. package/dist/src/storage/providers/sqlite/repos/identity.js +10 -4
  274. package/dist/src/storage/providers/sqlite/repos/identity.js.map +1 -1
  275. package/dist/src/storage/providers/sqlite/schema.d.ts +16 -3
  276. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  277. package/dist/src/storage/providers/sqlite/schema.js +31 -4
  278. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  279. package/dist/src/storage/repositories/app-grant.repository.d.ts +22 -0
  280. package/dist/src/storage/repositories/app-grant.repository.d.ts.map +1 -0
  281. package/dist/src/storage/repositories/app-grant.repository.js +2 -0
  282. package/dist/src/storage/repositories/app-grant.repository.js.map +1 -0
  283. package/dist/src/storage/repositories/app.repository.d.ts +9 -0
  284. package/dist/src/storage/repositories/app.repository.d.ts.map +1 -1
  285. package/dist/src/storage/repositories/identity.repository.d.ts +1 -0
  286. package/dist/src/storage/repositories/identity.repository.d.ts.map +1 -1
  287. package/dist/src/storage/repositories/index.d.ts +1 -0
  288. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  289. package/dist/src/utils/env-config.d.ts +5 -2
  290. package/dist/src/utils/env-config.d.ts.map +1 -1
  291. package/dist/src/utils/env-config.js +63 -2
  292. package/dist/src/utils/env-config.js.map +1 -1
  293. package/dist/src/utils/env-validator.d.ts +6 -3
  294. package/dist/src/utils/env-validator.d.ts.map +1 -1
  295. package/dist/src/utils/env-validator.js +14 -3
  296. package/dist/src/utils/env-validator.js.map +1 -1
  297. package/dist/src/utils/url-validator.d.ts +15 -0
  298. package/dist/src/utils/url-validator.d.ts.map +1 -1
  299. package/dist/src/utils/url-validator.js +120 -30
  300. package/dist/src/utils/url-validator.js.map +1 -1
  301. package/dist/static/app-catalog.html +505 -30
  302. package/dist/static/app-login.js +67 -0
  303. package/dist/static/app-silent.html +14 -0
  304. package/dist/static/app-silent.js +32 -0
  305. package/package.json +3 -1
  306. package/prisma/schema.postgres.prisma +39 -18
  307. package/prisma/schema.prisma +21 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @file app-grant.js
3
+ * @description Trusted consent page (apex origin) for the H-2 app-grant flow. An app on the
4
+ * isolated app origin sends the owner here (GET /v1/app-grants/authorize → 302 → /v1/app-grant
5
+ * ?req=...). The owner, authenticated on aimeat.io, reviews the requesting app + the exact
6
+ * scopes and Allows or Denies. On Allow we POST /v1/app-grants/authorize-consent and get back the
7
+ * app's redirect_url carrying a one-time code. Two delivery modes:
8
+ * • web_message (popup): postMessage { type:'aimeat_app_grant', code, state } to the app
9
+ * popup-opener (targeted at the exact app origin) and close — the user stays in the app.
10
+ * • query (full redirect, legacy): navigate to redirect_url.
11
+ * The app never sees the session, only the scoped, revocable grant token it then exchanges.
12
+ * "Advanced" lets the owner grant a SUBSET of the requested scopes (never more).
13
+ * @structure default export AppGrant() — loads the pending request, renders the trust prompt +
14
+ * scopes (+ advanced per-scope checkboxes), Allow/Deny.
15
+ * @usage routed at /v1/app-grant?req=<id> by spa.html
16
+ * @version-history
17
+ * v1.0.0 — 2026-06-20 — Initial (H-2 app-origin isolation, Phase 3: consent page).
18
+ * v1.1.0 — 2026-06-20 — Popup (web_message) delivery + "trust this app" reframe + Advanced
19
+ * per-scope subset selection (consent flow wired to apps via the SDK).
20
+ */
21
+ import { h } from 'preact';
22
+ import { useState, useEffect } from 'preact/hooks';
23
+ import htm from 'htm';
24
+ import { t } from '/js/i18n.js';
25
+ import { api } from '/js/api.js';
26
+ import { escHtml } from '/js/utils.js';
27
+
28
+ const html = htm.bind(h);
29
+ const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
30
+
31
+ export default function AppGrant() {
32
+ const [state, setState] = useState({ status: 'loading', request: null, error: '' });
33
+ const [submitting, setSubmitting] = useState(false);
34
+ const [advanced, setAdvanced] = useState(false);
35
+ const [selected, setSelected] = useState(() => new Set());
36
+ const [existingGrant, setExistingGrant] = useState(null); // the grant this app already holds (manage mode)
37
+
38
+ const requestId = new URLSearchParams(window.location.search).get('req') || '';
39
+ const [authed, setAuthed] = useState(() => !!window.AIMEAT?.auth?.hasSession);
40
+
41
+ // Become reactive to login: the consent popup may open with no one logged in (login_required), and
42
+ // the user signs in right here (auth.showLoginModal) — flip to the consent view on success.
43
+ useEffect(() => {
44
+ const onLogin = () => setAuthed(true);
45
+ window.AIMEAT?.auth?.on?.('login', onLogin);
46
+ return () => window.AIMEAT?.auth?.off?.('login', onLogin);
47
+ }, []);
48
+
49
+ function doLogin() {
50
+ if (window.AIMEAT?.auth?.showLoginModal) window.AIMEAT.auth.showLoginModal({ onLogin: () => setAuthed(true) });
51
+ else window.location.href = '/v1/profile';
52
+ }
53
+
54
+ useEffect(() => {
55
+ if (!requestId) { setState({ status: 'error', error: tr('appGrant.missing', 'No authorization request.') }); return; }
56
+ if (!authed) { setState({ status: 'login' }); return; }
57
+ let live = true;
58
+ api(`/v1/app-grants/request/${encodeURIComponent(requestId)}`)
59
+ .then(async (res) => {
60
+ if (!live) return;
61
+ // If this app already holds a grant, this is "manage mode": pre-check the currently granted
62
+ // scopes and offer Revoke. Best-effort — a failed lookup just falls back to first-consent.
63
+ let grant = null;
64
+ try {
65
+ const list = await api('/v1/app-grants');
66
+ grant = (list.data?.grants || []).find((g) => g.app === res.data.app) || null;
67
+ } catch { /* ignore */ }
68
+ if (!live) return;
69
+ const reqScopes = (res.data.scopes || []).map((s) => s.scope);
70
+ const granted = grant ? grant.scopes : reqScopes;
71
+ setExistingGrant(grant);
72
+ setSelected(new Set(granted));
73
+ // Pass-through: when NOT explicitly managing (the gear) and the app already holds a grant that
74
+ // covers what it's asking for, just approve silently — no second prompt for an app you trust.
75
+ const covers = grant && reqScopes.every((s) => grant.scopes.includes(s));
76
+ if (grant && !res.data.manage && covers) { setState({ status: 'autoapprove', request: res.data }); return; }
77
+ if (grant) setAdvanced(true); // manage / add-scopes → show the per-scope checkboxes up front
78
+ setState({ status: 'ready', request: res.data });
79
+ })
80
+ .catch((e) => { if (live) setState({ status: 'error', error: e.message || tr('appGrant.expired', 'This request has expired.') }); });
81
+ return () => { live = false; };
82
+ }, [requestId, authed]);
83
+
84
+ // Pass-through approval (already-granted app, not managing): approve once, silently.
85
+ useEffect(() => {
86
+ if (state.status === 'autoapprove' && !submitting) approve();
87
+ // eslint-disable-next-line
88
+ }, [state.status]);
89
+
90
+ async function revoke() {
91
+ if (!existingGrant) return;
92
+ setSubmitting(true);
93
+ try {
94
+ await api(`/v1/app-grants/${encodeURIComponent(existingGrant.grant_id)}`, { method: 'DELETE' });
95
+ if (state.request?.response_mode === 'web_message' && window.opener) {
96
+ window.opener.postMessage({ type: 'aimeat_app_grant', revoked: true, state: state.request.state }, state.request.app_origin);
97
+ window.close();
98
+ return;
99
+ }
100
+ window.location.href = state.request?.app_origin || '/v1/profile';
101
+ } catch (e) {
102
+ setState((s) => ({ ...s, status: 'error', error: e.message || 'Failed to revoke.' }));
103
+ setSubmitting(false);
104
+ }
105
+ }
106
+
107
+ function toggle(scope) {
108
+ setSelected((prev) => { const n = new Set(prev); if (n.has(scope)) n.delete(scope); else n.add(scope); return n; });
109
+ }
110
+
111
+ async function approve() {
112
+ if (selected.size === 0) return;
113
+ setSubmitting(true);
114
+ try {
115
+ const res = await api('/v1/app-grants/authorize-consent', {
116
+ method: 'POST', body: JSON.stringify({ request_id: requestId, scopes: [...selected] }),
117
+ });
118
+ const url = new URL(res.data.redirect_url);
119
+ const code = url.searchParams.get('code');
120
+ const st = url.searchParams.get('state');
121
+ // Popup mode: hand the code back to the app (popup-opener) at its exact origin, then close.
122
+ if (state.request?.response_mode === 'web_message' && window.opener) {
123
+ window.opener.postMessage({ type: 'aimeat_app_grant', code, state: st }, state.request.app_origin);
124
+ window.close();
125
+ return;
126
+ }
127
+ window.location.href = res.data.redirect_url; // legacy full-redirect mode
128
+ } catch (e) {
129
+ setState((s) => ({ ...s, status: 'error', error: e.message || 'Failed to approve.' }));
130
+ setSubmitting(false);
131
+ }
132
+ }
133
+
134
+ function deny() {
135
+ if (state.request?.response_mode === 'web_message' && window.opener) {
136
+ window.opener.postMessage({ type: 'aimeat_app_grant', code: null, state: null }, state.request.app_origin);
137
+ window.close();
138
+ return;
139
+ }
140
+ const origin = state.request?.app_origin;
141
+ window.location.href = origin || '/v1/profile';
142
+ }
143
+
144
+ if (state.status === 'loading' || state.status === 'autoapprove') {
145
+ const msg = state.status === 'autoapprove' ? tr('appGrant.signingIn', 'Signing you in…') : tr('common.loading', 'Loading…');
146
+ return html`<div class="agr-wrap"><p class="agr-muted">${msg}</p></div>`;
147
+ }
148
+ if (state.status === 'login') {
149
+ return html`
150
+ <div class="agr-wrap">
151
+ <div class="agr-card">
152
+ <h1 class="agr-title">${tr('appGrant.loginTitle', 'Log in to continue')}</h1>
153
+ <p class="agr-muted">${tr('appGrant.loginBody', 'Log in on aimeat.io, then re-open the app’s link to approve access.')}</p>
154
+ <button class="btn-primary agr-btn" onClick=${doLogin}>${tr('appGrant.loginCta', 'Log in')}</button>
155
+ </div>
156
+ </div>`;
157
+ }
158
+ if (state.status === 'error') {
159
+ return html`
160
+ <div class="agr-wrap">
161
+ <div class="agr-card">
162
+ <h1 class="agr-title">${tr('appGrant.errorTitle', 'Cannot grant access')}</h1>
163
+ <p class="agr-muted">${escHtml(state.error)}</p>
164
+ <button class="btn-outline agr-btn" onClick=${deny}>${tr('common.back', 'Back')}</button>
165
+ </div>
166
+ </div>`;
167
+ }
168
+
169
+ const req = state.request;
170
+ return html`
171
+ <div class="agr-wrap">
172
+ <div class="agr-card">
173
+ <span class="agr-badge">${tr('appGrant.externalBadge', 'External app')}</span>
174
+ <h1 class="agr-title">${existingGrant ? tr('appGrant.manageTitle', 'Manage this app’s access') : tr('appGrant.trustTitle', 'Trust this app?')}</h1>
175
+ <div class="agr-app">
176
+ <div class="agr-app-name">${escHtml(req.app_name)}</div>
177
+ <div class="agr-app-origin">${escHtml(req.app_origin)}</div>
178
+ </div>
179
+ <p class="agr-muted">${tr('appGrant.trustIntro', 'This app is not yours. It gets its OWN scoped, revocable key — never your login session. You can revoke it anytime in Profile › Access.')}</p>
180
+
181
+ <div class="agr-scopes-label">${tr('appGrant.needsLabel', 'This app needs:')}</div>
182
+ <ul class="agr-scopes">
183
+ ${req.scopes.map((s) => html`
184
+ <li class="agr-scope" key=${s.scope}>
185
+ ${advanced && html`
186
+ <input type="checkbox" class="agr-scope-check" checked=${selected.has(s.scope)}
187
+ onChange=${() => toggle(s.scope)} aria-label=${s.scope} />`}
188
+ <span class="agr-scope-text">
189
+ <span class="agr-scope-desc">${escHtml(s.description || s.scope)}</span>
190
+ <span class="agr-scope-name">${escHtml(s.scope)}</span>
191
+ </span>
192
+ </li>`)}
193
+ </ul>
194
+
195
+ <button class="agr-advanced-toggle" onClick=${() => setAdvanced((v) => !v)}>
196
+ ${advanced ? tr('appGrant.advancedHide', '▲ Hide advanced') : tr('appGrant.advancedShow', '⚙ Advanced — choose permissions')}
197
+ </button>
198
+
199
+ <div class="agr-actions">
200
+ ${existingGrant
201
+ ? html`<button class="btn-danger agr-btn" onClick=${revoke} disabled=${submitting}>${tr('appGrant.revoke', 'Revoke access')}</button>`
202
+ : html`<button class="btn-outline agr-btn" onClick=${deny} disabled=${submitting}>${tr('appGrant.deny', 'Don’t trust')}</button>`}
203
+ <button class="btn-primary agr-btn" onClick=${approve} disabled=${submitting || selected.size === 0}>
204
+ ${submitting ? tr('appGrant.approving', 'Allowing…') : (existingGrant ? tr('appGrant.saveCta', 'Save changes') : tr('appGrant.trustCta', '❤ Trust — allow access'))}
205
+ </button>
206
+ </div>
207
+ ${!existingGrant && html`<p class="agr-next">${tr('appGrant.nextNote', 'Next time this app logs you in automatically — no prompt.')}</p>`}
208
+ </div>
209
+ </div>`;
210
+ }
@@ -9,6 +9,8 @@
9
9
  * @usage routed at /v1/business by spa.html
10
10
  * @version-history
11
11
  * v1.0.0 — 2026-06-10 — Initial (owner spec).
12
+ * v1.1.0 — 2026-06-20 — H-2: published-app proof links open in a sandboxed opaque-origin
13
+ * iframe (openAppSandboxed) instead of a top-level apex ?mode=inline tab.
12
14
  */
13
15
  import { h } from 'preact';
14
16
  import { useState, useEffect } from 'preact/hooks';
@@ -17,6 +19,7 @@ const html = htm.bind(h);
17
19
  import { t } from '/js/i18n.js';
18
20
  import { escHtml } from '/js/utils.js';
19
21
  import { ContactCard } from '/components/ContactCard.js';
22
+ import { openAppSandboxed, isAppHtmlUrl } from '/js/app-sandbox.js';
20
23
 
21
24
  const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
22
25
  const CONTACT = 'mailto:jouni.miikki@aimeat.io';
@@ -42,7 +45,11 @@ export default function Business({ navigate }) {
42
45
  <p class="ld-case-text">${text}</p>
43
46
  ${quote && html`<blockquote class="ld-case-quote">“${quote}”<footer>— ${quoteBy}</footer></blockquote>`}
44
47
  ${proofHref
45
- ? html`<a class="ld-case-proof" href=${proofHref} target="_blank" rel="noopener">${proofLabel}</a>`
48
+ // H-2: published-app proof links open in a sandboxed opaque-origin iframe, never a
49
+ // top-level apex document. Non-app evidence (public viewers, external sites) opens normally.
50
+ ? (isAppHtmlUrl(proofHref)
51
+ ? html`<a class="ld-case-proof" href="#" role="button" onClick=${(e) => { e.preventDefault(); openAppSandboxed(proofHref, proofLabel); }}>${proofLabel}</a>`
52
+ : html`<a class="ld-case-proof" href=${proofHref} target="_blank" rel="noopener">${proofLabel}</a>`)
46
53
  : proofNote && html`<div class="ld-case-proofnote">${proofNote}</div>`}
47
54
  <a class="ld-path-cta" href=${ctaHref}>${ctaLabel}</a>
48
55
  </div>
@@ -8,6 +8,8 @@
8
8
  * @usage routed at /v1/how-it-works by spa.html
9
9
  * @version-history
10
10
  * v1.0.0 — 2026-06-10 — Initial (owner spec).
11
+ * v1.1.0 — 2026-06-20 — H-2: the live-example app link opens in a sandboxed opaque-origin
12
+ * iframe (openAppSandboxed) instead of a top-level apex ?mode=inline tab.
11
13
  */
12
14
  import { h } from 'preact';
13
15
  import { useState, useEffect } from 'preact/hooks';
@@ -15,6 +17,7 @@ import htm from 'htm';
15
17
  const html = htm.bind(h);
16
18
  import { t } from '/js/i18n.js';
17
19
  import { escHtml } from '/js/utils.js';
20
+ import { openAppSandboxed } from '/js/app-sandbox.js';
18
21
 
19
22
  const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
20
23
 
@@ -144,7 +147,8 @@ export default function HowItWorks({ navigate }) {
144
147
  <div class="ld-stats-own">
145
148
  ${tr('hiw.exampleText', 'AIMEAT Sanomat writes itself every evening. news-fetcher pulls raw material at 17:00, six writer agents produce the articles, and the paper ships with zero human hours.')}
146
149
  ${paper && html`
147
- <a class="ld-stats-cta" href=${`/v1/apps/${encodeURIComponent(paper.owner)}/${encodeURIComponent(paper.filename)}?mode=inline`} target="_blank">
150
+ <a class="ld-stats-cta" href="#" role="button" onClick=${(e) => { e.preventDefault();
151
+ openAppSandboxed(`/v1/apps/${encodeURIComponent(paper.owner)}/${encodeURIComponent(paper.filename)}?mode=inline`, paper.filename); }}>
148
152
  ${tr('hiw.exampleCta', "Read tonight's paper →")}</a>`}
149
153
  </div>
150
154
  </div>
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @file landing-node-totals.js
3
+ * @description Landing-page "Happening on this node" panel. Replaces the previously
4
+ * often-empty 3-tab activity feed with cumulative, never-empty node counters:
5
+ * public apps, public organisms, connected agents (+ online now), public knowledge
6
+ * packages, and total app downloads. One fetch of GET /v1/public/node-totals on
7
+ * mount, refreshed on the aimeat-live-update window event and on a slow interval.
8
+ * Rationale: an empty feed reads as "broken"; counts always show the node is alive.
9
+ * @structure default export NodeTotals() — sibling component imported by landing.js.
10
+ * @usage import NodeTotals from './landing-node-totals.js'; <${NodeTotals} />
11
+ * @version-history
12
+ * v1.0.0 — 2026-06-20 — Initial: cumulative node counters replacing the activity feed.
13
+ */
14
+ import { h } from 'preact';
15
+ import { useState, useEffect } from 'preact/hooks';
16
+ import htm from 'htm';
17
+ const html = htm.bind(h);
18
+ import { t } from '/js/i18n.js';
19
+
20
+ const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
21
+ const num = (n) => (Number(n) || 0).toLocaleString();
22
+
23
+ export default function NodeTotals() {
24
+ const [totals, setTotals] = useState(null);
25
+
26
+ useEffect(() => {
27
+ let alive = true;
28
+ const load = () => {
29
+ fetch('/v1/public/node-totals')
30
+ .then(r => r.json())
31
+ .then(j => { if (alive && j && j.ok !== false && j.data) setTotals(j.data); })
32
+ .catch(() => { /* keep last value — panel renders a graceful placeholder */ });
33
+ };
34
+ load();
35
+ const onLive = () => load();
36
+ window.addEventListener('aimeat-live-update', onLive);
37
+ const iv = setInterval(load, 60_000);
38
+ return () => {
39
+ alive = false;
40
+ window.removeEventListener('aimeat-live-update', onLive);
41
+ clearInterval(iv);
42
+ };
43
+ }, []);
44
+
45
+ const d = totals || {};
46
+ const cards = [
47
+ { icon: '🎮', value: d.apps, label: tr('landing.totalApps', 'public apps') },
48
+ { icon: '🧬', value: d.organisms, label: tr('landing.totalOrganisms', 'organisms') },
49
+ {
50
+ icon: '🤖', value: d.agents, label: tr('landing.totalAgents', 'agents'),
51
+ sub: d.agents_online > 0 ? `${num(d.agents_online)} ${tr('landing.totalOnline', 'online now')}` : null,
52
+ },
53
+ { icon: '📚', value: d.knowledge_packages, label: tr('landing.totalKnowledge', 'knowledge packages') },
54
+ { icon: '⬇️', value: d.downloads, label: tr('landing.totalDownloads', 'app downloads') },
55
+ ];
56
+
57
+ return html`
58
+ <section class="pa nt">
59
+ <h2 class="pa-title">${tr('landing.feedTitle', 'Happening on this node')}</h2>
60
+ <p class="pa-sub">${tr('landing.totalsSub', "What's live on this node right now.")}</p>
61
+ <div class="nt-grid">
62
+ ${cards.map(c => html`
63
+ <div class="nt-card" key=${c.label}>
64
+ <span class="nt-icon">${c.icon}</span>
65
+ <span class="nt-value">${totals ? num(c.value) : '–'}</span>
66
+ <span class="nt-label">${c.label}</span>
67
+ ${c.sub ? html`<span class="nt-sub">${c.sub}</span>` : null}
68
+ </div>`)}
69
+ </div>
70
+ </section>`;
71
+ }
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * @file landing.js
3
- * @description Logged-out landing page: reward first, explanation second. Hero is a
4
- * newspaper-framed teaser for tonight's Sanomat that opens the REAL app in one click —
5
- * no iframe (mobile-friendly) and no fake image: a designed masthead card by default,
6
- * auto-upgrading to a real screenshot if/when one is uploaded (apps API screenshot_url
7
- * only). Two CTAs (try it free · get your own) → proof gallery → live activity feed →
3
+ * @description Logged-out landing page: reward first, explanation second. Hero is the
4
+ * build-prompt pitch — copy one prompt and your AI builds you a real app on AIMEAT that you
5
+ * own and publish. Two CTAs (copy build prompt · get your own) → LIVE wall of the real apps
6
+ * people published here (manifest-driven, from the apps API) → node totals panel →
8
7
  * the 3-step build loop + build prompt → "ask your own AI" → today's node stats +
9
8
  * ownership line (the sales close) → footer. Logged-in visitors are forwarded to the
10
9
  * profile Home dashboard. No protocol terms (GHII/GAII/CSM/federation) above the fold;
11
10
  * a working result does the selling.
12
- * @structure default export Landing({ navigate }) + Hero/StatsPanel/BuildAppPrompt/BuildAgentPrompt/AskYourAI/Gallery
11
+ * @structure default export Landing({ navigate }) + BuildHero/Gallery(live wall)/StatsPanel/BuildAppPrompt/BuildAgentPrompt/AskYourAI
13
12
  * @usage routed at /v1/portal (and '/' for browsers) by spa.html
14
13
  * @version-history
15
14
  * v1.0.0 — 2026-06-10 — Initial: landing/portal split (owner spec).
@@ -24,6 +23,15 @@
24
23
  * v2.1.0 — 2026-06-17 — Add BuildAgentPrompt: a copy-paste "build an agent in 10 minutes" prompt
25
24
  * for the local crewaimeat fleet (Ollama/Gemma, no keys); Hero "Get your own →" now points to
26
25
  * the desktop installer GitHub Release (was /v1/pricing).
26
+ * v3.0.0 — 2026-06-20 — Value-first hero: replace the Sanomat newspaper Hero with BuildHero
27
+ * (copy the build prompt → your AI builds you an app you own + publish); the gallery becomes a
28
+ * LIVE wall of the real apps people published here (manifest-driven from /v1/apps).
29
+ * v3.1.0 — 2026-06-20 — Wall: fixed 3-up grid + filter search; cards show author + publish
30
+ * date/time. Hero subline adds "let your agents keep it running" (Sanomat as the example).
31
+ * v3.2.0 — 2026-06-20 — H-2: wall cards open published apps in a sandboxed opaque-origin
32
+ * iframe (openAppSandboxed) instead of a top-level apex ?mode=inline link.
33
+ * v3.3.0 — 2026-06-20 — Replace PublicActivityFeed (read as broken when empty) with NodeTotals:
34
+ * cumulative "this node has X" counters (apps/organisms/agents+online/knowledge/downloads).
27
35
  */
28
36
  import { h } from 'preact';
29
37
  import { useState, useEffect } from 'preact/hooks';
@@ -31,7 +39,8 @@ import htm from 'htm';
31
39
  const html = htm.bind(h);
32
40
  import { t } from '/js/i18n.js';
33
41
  import { escHtml } from '/js/utils.js';
34
- import PublicActivityFeed from './landing-activity.js';
42
+ import { openAppSandboxed } from '/js/app-sandbox.js';
43
+ import NodeTotals from './landing-node-totals.js';
35
44
 
36
45
  // t() echoes the key when a translation is missing — fall back to readable English.
37
46
  const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
@@ -67,44 +76,63 @@ function StatsPanel({ navigate }) {
67
76
  `;
68
77
  }
69
78
 
70
- /* ── Proof gallery: six fully curated cards. Sanomat and Comicland lead because they
71
- work solo, instantly; Deep Six needs two players (badged). Proof, not a shop. ── */
72
- function Gallery({ onApps }) {
73
- const [, setApps] = useState([]);
79
+ /* ── Live wall — the REAL apps people built with their AI and published to this node (from the
80
+ apps API, manifest-driven). Three per row + a filter. Each card: name · description · who made
81
+ it · when. The proof the loop works: your creation lands on this same wall. ── */
82
+ function fmtPublished(iso) {
83
+ try {
84
+ const d = new Date(iso);
85
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
86
+ } catch { return ''; }
87
+ }
88
+
89
+ function Gallery() {
90
+ const [apps, setApps] = useState([]);
91
+ const [q, setQ] = useState('');
74
92
  useEffect(() => {
75
- // The catalog still feeds card A's local-Sanomat lookup via onApps.
76
- fetch('/v1/apps?sort=popular&limit=50').then(r => r.json())
77
- .then(j => { const list = j?.data?.apps || []; setApps(list); onApps?.(list); })
78
- .catch(() => { /* curated cards render regardless */ });
93
+ fetch('/v1/apps?sort=popular&limit=60').then(r => r.json())
94
+ .then(j => setApps(j?.data?.apps || []))
95
+ .catch(() => { /* empty state renders */ });
79
96
  }, []);
80
97
 
81
- const cards = [
82
- { name: 'AIMEAT Sanomat', desc: tr('landing.gallerySanomat', 'The paper that writes itself every evening. Six agents, zero human hours.'),
83
- href: 'https://aimeat.io/v1/apps/happydude500001/laimeat-sanomat.html?mode=inline' },
84
- { name: 'Comicland', desc: tr('landing.galleryComicland', 'Comics by agents — browse the catalog.'),
85
- href: 'https://aimeat.io/v1/apps/happydude500001/comicland-v2-app.html?mode=inline#/catalog' },
86
- { name: 'Battleship (Deep Six)', desc: tr('landing.galleryDeepSix', 'Two-player battleship on an agent platform. Bring an opponent — or two browsers.'),
87
- href: 'https://aimeat.io/v1/apps/anonymous/deep-six.html?mode=inline', badge: tr('landing.twoPlayers', '2 players') },
88
- { name: 'Solar System Simulator', desc: tr('landing.gallerySolar', 'Simulate the solar system.'),
89
- href: 'https://aimeat.io/v1/apps/anonymous/solar-system.html?mode=inline' },
90
- { name: 'Math Graph 3D', desc: tr('landing.galleryGraph', 'Plot 3D math functions in the browser.'),
91
- href: 'https://aimeat.io/v1/apps/anonymous/graph.html?mode=inline' },
92
- { name: 'Band Jam', desc: tr('landing.galleryBandJam', 'Make music with your friends, live!'),
93
- href: 'https://aimeat.io/v1/apps/anonymous/band-jam.html?mode=inline' },
94
- ];
98
+ const ql = q.trim().toLowerCase();
99
+ const shown = !ql ? apps : apps.filter((a) => {
100
+ const m = a.manifest || {};
101
+ return [m.name, m.description, m.authorDisplay, a.owner].some(v => (v || '').toLowerCase().includes(ql));
102
+ });
95
103
 
96
104
  return html`
97
105
  <div class="ld-section">
98
- <h2 class="ld-h2">${tr('landing.galleryTitle', 'Built with this loop — real apps, try them')}</h2>
99
- <div class="ld-gallery">
100
- ${cards.map(c => html`
101
- <a key=${c.href} class="ld-app-card" href=${c.href} target="_blank" rel="noopener">
102
- <div class="ld-app-name">${escHtml(c.name)}${c.badge && html` <span class="ld-app-badge">${c.badge}</span>`}</div>
103
- ${c.desc && html`<div class="ld-app-desc">${escHtml(c.desc)}</div>`}
104
- <div class="ld-app-meta">${tr('landing.builtInChat', 'built in an AI chat session')}</div>
105
- </a>
106
- `)}
107
- </div>
106
+ <h2 class="ld-h2">${tr('landing.wallTitle', 'Built by people with their AI. Yours goes here too.')}</h2>
107
+ <input class="ld-wall-search" type="search" value=${q}
108
+ onInput=${(e) => setQ(e.target.value)}
109
+ placeholder=${tr('landing.wallSearch', 'Search apps…')}
110
+ aria-label=${tr('landing.wallSearch', 'Search apps')} />
111
+ ${shown.length === 0
112
+ ? html`<p class="ld-app-desc">${apps.length === 0
113
+ ? tr('landing.wallEmpty', 'Be the first — copy the prompt above, build something, and it lands here.')
114
+ : tr('landing.wallNoMatch', 'No apps match your search.')}</p>`
115
+ : html`<div class="ld-gallery">
116
+ ${shown.map((a) => {
117
+ const m = a.manifest || {};
118
+ // H-2: open published apps in a sandboxed (opaque-origin) iframe, never as a
119
+ // top-level apex document. Click-to-open instead of an apex href, so middle-/
120
+ // ctrl-click can't bypass it either.
121
+ const href = `/v1/apps/${encodeURIComponent(a.owner)}/${encodeURIComponent(a.filename)}?mode=inline`;
122
+ const desc = (m.description || '').length > 140 ? m.description.slice(0, 140) + '…' : (m.description || '');
123
+ const author = m.authorDisplay || a.owner || tr('landing.wallAnon', 'someone');
124
+ const when = a.created_at ? fmtPublished(a.created_at) : '';
125
+ const open = () => openAppSandboxed(href, m.name || a.filename);
126
+ return html`
127
+ <div key=${a.owner + '/' + a.filename} class="ld-app-card" role="button" tabindex="0"
128
+ onClick=${open} onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }}>
129
+ ${a.screenshot_url ? html`<img class="ld-app-shot" src=${a.screenshot_url} loading="lazy" alt="" />` : ''}
130
+ <div class="ld-app-name">${m.icon ? escHtml(m.icon) + ' ' : ''}${escHtml(m.name || a.filename)}</div>
131
+ ${desc && html`<div class="ld-app-desc">${escHtml(desc)}</div>`}
132
+ <div class="ld-app-meta">${escHtml(author)}${when ? ' · ' + when : ''}</div>
133
+ </div>`;
134
+ })}
135
+ </div>`}
108
136
  </div>
109
137
  `;
110
138
  }
@@ -232,6 +260,7 @@ function buildLandingAppPrompt(nodeUrl) {
232
260
  p += 'Always include <meta name="viewport" content="width=device-width, initial-scale=1.0">. Mobile-first, single self-contained HTML file with embedded CSS + JS.\n\n';
233
261
  p += '### Important Rules\n';
234
262
  p += '- Return the COMPLETE HTML file, not fragments\n';
263
+ p += '- Also write a one-sentence description of what the app does - it is REQUIRED when publishing and shows in the catalogue and on the landing wall\n';
235
264
  p += '- Never use literal closing script tags in JS comments or strings\n';
236
265
  p += '- Keep it as a single self-contained HTML file\n';
237
266
  p += '- Load only the libraries you actually use; load aimeat-auth before libs that need a session\n';
@@ -381,35 +410,23 @@ function AskYourAI() {
381
410
  </section>`;
382
411
  }
383
412
 
384
- /* ── Hero v2 — reward first: a REAL screenshot of tonight's Sanomat (no iframe, so it
385
- works on mobile), framed as a newspaper that opens the REAL app in one click. No fake
386
- image: it shows a designed masthead card by default and auto-upgrades to a real
387
- screenshot the day one is uploaded (we trust ONLY the apps API's screenshot_url, never a
388
- guessed URL — every app currently has none, so the card is what shows). The two CTAs are
389
- the only fork (experience it now · own it). ── */
390
- function Hero({ sanomat, tryHref }) {
391
- const shot = sanomat?.screenshot_url || null; // only a real, API-reported screenshot
413
+ /* ── Hero — value first: AIMEAT is a safe place to build real apps with your AI in minutes, and
414
+ you own + publish them. One copyable prompt is the whole on-ramp; the live wall below is the
415
+ proof that your creation lands on the same shelf as everyone else's. ── */
416
+ function BuildHero() {
417
+ const [copied, setCopied] = useState(false);
418
+ const prompt = buildLandingAppPrompt(window.location.origin);
419
+ const copy = async () => {
420
+ try { await navigator.clipboard.writeText(prompt); setCopied(true); setTimeout(() => setCopied(false), 2000); }
421
+ catch { /* the full prompt is also visible lower on the page */ }
422
+ };
392
423
  return html`
393
424
  <section class="ld-hero2">
394
- <p class="ld-hero2-kicker">${tr('landing.heroKicker', 'Tonight’s paper wrote itself.')}</p>
395
- <h1 class="ld-hero2-title">${tr('landing.heroLead', 'Six agents. Zero human hours.')}</h1>
396
- <a class="ld-hero2-shot" href=${tryHref} target="_blank" rel="noopener"
397
- aria-label=${tr('landing.heroOpenPaper', 'Open tonight’s paper')}>
398
- ${shot ? html`
399
- <img class="ld-hero2-img" src=${shot} loading="lazy"
400
- alt=${tr('landing.heroShotAlt', 'AIMEAT Sanomat — tonight’s edition')} />
401
- ` : html`
402
- <div class="ld-hero2-paper">
403
- <span class="ld-hero2-masthead">AIMEAT Sanomat</span>
404
- <span class="ld-hero2-paper-rule"></span>
405
- <span class="ld-hero2-paper-line">${tr('landing.heroPaperLine', 'Tonight’s edition · written entirely by agents')}</span>
406
- </div>
407
- `}
408
- <span class="ld-hero2-openbar">${tr('landing.cardACta', 'Read tonight’s paper →')}</span>
409
- </a>
410
- <p class="ld-hero2-sub">${tr('landing.heroSub2', 'A real app, built and run by AI on AIMEAT — look before you sign up.')}</p>
425
+ <p class="ld-hero2-kicker">${tr('landing.buildHeroKicker', 'A safe place to build with AI.')}</p>
426
+ <h1 class="ld-hero2-title">${tr('landing.buildHeroTitle', 'Build a real app with your AI in minutes, and it’s yours.')}</h1>
427
+ <p class="ld-hero2-sub">${tr('landing.buildHeroSub', 'Copy one prompt into Claude, ChatGPT or any AI. It builds you a working app on AIMEAT, published live and yours to keep. Then let your agents run it for you, the way AIMEAT Sanomat writes itself every evening.')}</p>
411
428
  <div class="ld-hero2-cta">
412
- <a class="btn-primary" href=${tryHref} target="_blank" rel="noopener">${tr('landing.heroTryFree', 'Try it free →')}</a>
429
+ <button class="btn-primary" type="button" onClick=${copy}>${copied ? tr('landing.buildHeroCopied', 'Copied ✓ — paste into your AI') : tr('landing.buildHeroCopy', 'Copy the build prompt →')}</button>
413
430
  <a class="btn-outline" href="https://github.com/miikkij/aimeat-protocol/releases/latest" target="_blank" rel="noopener">${tr('landing.heroGetOwn', 'Get your own →')}</a>
414
431
  </div>
415
432
  </section>
@@ -436,24 +453,16 @@ export default function Landing({ navigate }) {
436
453
  return () => window.removeEventListener('aimeat-auth-change', onAuth);
437
454
  }, []);
438
455
 
439
- const [apps, setApps] = useState([]);
440
- // Card A sends the solo visitor to Sanomat — an instant experience alone, no login.
441
- // (Battleship/Deep Six lives in the gallery with its "2 players" badge.)
442
- const sanomat = apps.find(a => /sanomat/i.test((a.filename || '') + ' ' + (a.name || '')));
443
- const tryHref = sanomat
444
- ? `/v1/apps/${encodeURIComponent(sanomat.owner)}/${encodeURIComponent(sanomat.filename)}?mode=inline`
445
- : 'https://aimeat.io/v1/apps/happydude500001/laimeat-sanomat.html?mode=inline';
446
-
447
456
  return html`
448
457
  <div class="ld">
449
- <!-- 1. Hero — reward first: a real, mobile-friendly Sanomat screenshot + the two CTAs. -->
450
- <${Hero} sanomat=${sanomat} tryHref=${tryHref} />
458
+ <!-- 1. Hero — value first: build a real app with your AI; the live wall below is the proof. -->
459
+ <${BuildHero} />
451
460
 
452
- <!-- 2. Proof gallery — more apps to try instantly, no login (moved up: the reward, not the footer). -->
453
- <${Gallery} onApps=${setApps} />
461
+ <!-- 2. Live wall — the real apps people built with their AI and published here (yours goes here). -->
462
+ <${Gallery} />
454
463
 
455
- <!-- 3. Public activity feed — live proof it's happening right now. -->
456
- <${PublicActivityFeed} />
464
+ <!-- 3. Node totals — cumulative "this node has X" counters; never empty, always proof it's alive. -->
465
+ <${NodeTotals} />
457
466
 
458
467
  <!-- 4. How building works: the 3-step loop + the copyable build prompt. -->
459
468
  <div class="ld-loop">