@app-vitals/agent-admin 0.0.1 → 0.1.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 (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * admin/src/pwa-routes.smoke.test.ts
3
+ * Smoke tests for the unauthenticated PWA shell routes: manifest.webmanifest,
4
+ * sw.js, and the six icon files. Uses app.request() — no real server, no
5
+ * real DB, no session cookie set on any request in this file (that's the
6
+ * point: browsers fetch these with credentials:omit).
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { AdminUIDeps } from "./admin-ui.ts";
14
+ import { createAdminUIApp } from "./admin-ui.ts";
15
+ import { PWA_ICONS } from "./pwa.ts";
16
+
17
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
18
+
19
+ const PNG_1X1 = Buffer.from(
20
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
21
+ "base64",
22
+ );
23
+
24
+ function makeFixtureIconsDir(): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "pwa-icons-"));
26
+ const iconsDir = join(dir, "icons");
27
+ mkdirSync(iconsDir, { recursive: true });
28
+ for (const icon of PWA_ICONS) {
29
+ writeFileSync(join(iconsDir, icon.filename), PNG_1X1);
30
+ }
31
+ return dir;
32
+ }
33
+
34
+ function makeMinimalDeps(overrides?: Partial<AdminUIDeps>): AdminUIDeps {
35
+ return {
36
+ prisma: {
37
+ agent: {
38
+ findMany: async () => [],
39
+ findUnique: async () => null,
40
+ create: async () => {
41
+ throw new Error("not implemented");
42
+ },
43
+ update: async () => {
44
+ throw new Error("not implemented");
45
+ },
46
+ delete: async () => {
47
+ throw new Error("not implemented");
48
+ },
49
+ },
50
+ agentEnv: { findMany: async () => [] },
51
+ agentPlugin: { findMany: async () => [] },
52
+ agentMember: {
53
+ findMany: async () => [],
54
+ findUnique: async () => null,
55
+ create: async () => {
56
+ throw new Error("not implemented");
57
+ },
58
+ deleteMany: async () => ({ count: 0 }),
59
+ },
60
+ },
61
+ agentEnvService: {
62
+ getByAgentId: async () => null,
63
+ upsert: async () => {},
64
+ patch: async () => {},
65
+ deleteKey: async () => {},
66
+ getConfigBundle: async () => null,
67
+ },
68
+ agentCronJobService: {
69
+ list: async () => [],
70
+ listWithRunSummary: async () => [],
71
+ get: async () => {
72
+ throw new Error("not implemented");
73
+ },
74
+ create: async () => {
75
+ throw new Error("not implemented");
76
+ },
77
+ setEnabled: async () => {
78
+ throw new Error("not implemented");
79
+ },
80
+ update: async () => {
81
+ throw new Error("not implemented");
82
+ },
83
+ delete: async () => {},
84
+ reconcileSystemCrons: async () => ({
85
+ created: 0,
86
+ updated: 0,
87
+ deleted: 0,
88
+ }),
89
+ },
90
+ agentToolService: {
91
+ list: async () => [],
92
+ add: async () => {
93
+ throw new Error("not implemented");
94
+ },
95
+ toggle: async () => {
96
+ throw new Error("not implemented");
97
+ },
98
+ remove: async () => {},
99
+ },
100
+ agentTokenService: {
101
+ listForAgent: async () => [],
102
+ create: async () => {
103
+ throw new Error("not implemented");
104
+ },
105
+ revoke: async () => {
106
+ throw new Error("not implemented");
107
+ },
108
+ },
109
+ agentPluginService: {
110
+ list: async () => [],
111
+ add: async () => {
112
+ throw new Error("not implemented");
113
+ },
114
+ },
115
+ agentMemberService: {
116
+ listByEmail: async () => [],
117
+ exists: async () => false,
118
+ add: async () => {
119
+ throw new Error("not implemented");
120
+ },
121
+ remove: async () => {},
122
+ listByAgentId: async () => [],
123
+ },
124
+ agentService: {
125
+ listAll: async () => [],
126
+ listByIds: async () => [],
127
+ searchByName: async () => [],
128
+ listOptions: async () => [],
129
+ create: async () => {
130
+ throw new Error("not implemented");
131
+ },
132
+ delete: async () => {},
133
+ getDetail: async () => null,
134
+ updateFields: async () => {
135
+ throw new Error("not implemented");
136
+ },
137
+ },
138
+ provisioner: {
139
+ canProvision: false,
140
+ provision: async () => {
141
+ throw new Error("not implemented");
142
+ },
143
+ deprovision: async () => {},
144
+ reconcile: async () => ({
145
+ recreated: [],
146
+ migrated: [],
147
+ noOwner: [],
148
+ dryRun: false,
149
+ updated: [],
150
+ orphans: [],
151
+ failed: [],
152
+ }),
153
+ },
154
+ serviceIntegrations: [
155
+ {
156
+ name: "task-store",
157
+ listTokensForAgent: async () => [],
158
+ revokeToken: async () => {},
159
+ },
160
+ ],
161
+ chatService: {
162
+ listTokensForAgent: async () => [],
163
+ revokeToken: async () => {},
164
+ deleteThreadsForAgent: async () => ({ deleted: 0 }),
165
+ },
166
+ slack: { deleteApp: async () => {} },
167
+ decrypt: (value: string) => value,
168
+ sessionSecret: SESSION_SECRET,
169
+ googleClient: {
170
+ exchangeCode: async () => ({
171
+ accessToken: "t",
172
+ refreshToken: "r",
173
+ expiresIn: 3600,
174
+ }),
175
+ getUserInfo: async () => ({
176
+ sub: "s",
177
+ email: "a@example.com",
178
+ email_verified: true,
179
+ name: "A",
180
+ }),
181
+ },
182
+ googleClientId: "gcid",
183
+ googleClientSecret: "gcsecret",
184
+ adminAllowedEmails: ["admin@example.com"],
185
+ slackClient: {
186
+ createAppManifest: async () => {
187
+ throw new Error("not implemented");
188
+ },
189
+ updateAppManifest: async () => {},
190
+ exchangeOAuthCode: async () => ({ botToken: "xoxb" }),
191
+ authTest: async () => ({ userId: "U0AALR8M69X" }),
192
+ },
193
+ githubAppClient: {
194
+ exchangeManifestCode: async () => {
195
+ throw new Error("not implemented");
196
+ },
197
+ },
198
+ appBaseUrl: "https://example.com",
199
+ ...overrides,
200
+ };
201
+ }
202
+
203
+ describe("PWA shell routes — unauthenticated reachability", () => {
204
+ const iconsDir = makeFixtureIconsDir();
205
+ const deps = makeMinimalDeps({ pwaAssetsDir: iconsDir });
206
+ const app = createAdminUIApp(deps);
207
+
208
+ it("GET /admin/manifest.webmanifest is reachable without a session cookie", async () => {
209
+ const res = await app.request("/admin/manifest.webmanifest");
210
+ expect(res.status).toBe(200);
211
+ expect(res.headers.get("Content-Type")).toContain(
212
+ "application/manifest+json",
213
+ );
214
+ const body = await res.json();
215
+ expect(body.scope).toBe("/admin/");
216
+ expect(body.start_url).toBe("/admin/chat");
217
+ expect(body.display).toBe("standalone");
218
+ });
219
+
220
+ it("GET /admin/manifest.webmanifest?start=... honors a valid same-origin start param (PWA-1.1)", async () => {
221
+ const res = await app.request(
222
+ "/admin/manifest.webmanifest?start=%2Fadmin%2Ftasks",
223
+ );
224
+ expect(res.status).toBe(200);
225
+ const body = await res.json();
226
+ expect(body.start_url).toBe("/admin/tasks");
227
+ });
228
+
229
+ it("GET /admin/manifest.webmanifest?start=... falls back to /admin/chat for a malicious/invalid start param (PWA-1.1)", async () => {
230
+ const res = await app.request(
231
+ `/admin/manifest.webmanifest?start=${encodeURIComponent("https://evil.com")}`,
232
+ );
233
+ expect(res.status).toBe(200);
234
+ const body = await res.json();
235
+ expect(body.start_url).toBe("/admin/chat");
236
+ });
237
+
238
+ it("GET /admin/sw.js is reachable without a session cookie, no-cache, correct content type", async () => {
239
+ const res = await app.request("/admin/sw.js");
240
+ expect(res.status).toBe(200);
241
+ expect(res.headers.get("Content-Type")).toContain("javascript");
242
+ expect(res.headers.get("Cache-Control")).toBe("no-cache");
243
+ });
244
+
245
+ it("GET /admin/icons/:filename is reachable without a session cookie for every declared icon", async () => {
246
+ for (const icon of PWA_ICONS) {
247
+ const res = await app.request(`/admin/icons/${icon.filename}`);
248
+ expect(res.status).toBe(200);
249
+ expect(res.headers.get("Content-Type")).toBe("image/png");
250
+ }
251
+ });
252
+
253
+ it("GET /admin/icons/:filename 404s for an unknown filename", async () => {
254
+ const res = await app.request("/admin/icons/does-not-exist.png");
255
+ expect(res.status).toBe(404);
256
+ });
257
+
258
+ it("GET /admin/offline.html is reachable without a session cookie", async () => {
259
+ const res = await app.request("/admin/offline.html");
260
+ expect(res.status).toBe(200);
261
+ expect(res.headers.get("Content-Type")).toContain("text/html");
262
+ const body = await res.text();
263
+ expect(body).toContain("You're offline");
264
+ });
265
+ });
266
+
267
+ describe("html() security headers", () => {
268
+ it("every rendered admin page carries the three security headers", async () => {
269
+ const iconsDir2 = makeFixtureIconsDir();
270
+ const app = createAdminUIApp(makeMinimalDeps({ pwaAssetsDir: iconsDir2 }));
271
+ const res = await app.request("/admin/login");
272
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
273
+ expect(res.headers.get("X-Frame-Options")).toBe("DENY");
274
+ expect(res.headers.get("Referrer-Policy")).toBeTruthy();
275
+ });
276
+ });
package/src/pwa.ts ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * admin/src/pwa.ts
3
+ * Installable PWA shell for the admin console: manifest, service worker
4
+ * body, and the icon metadata that both the icon-generation script and the
5
+ * icon-serving route consume.
6
+ *
7
+ * Every function here is pure — no I/O, no Hono, no filesystem access —
8
+ * so the caching contract (what gets precached, what a live fetch handler
9
+ * is allowed to cache) is unit-testable without a real ServiceWorker or
10
+ * browser environment. Route registration and static-file reads live in
11
+ * admin-ui.ts, mirroring the metrics/src/api.ts STATIC_FILES pattern.
12
+ *
13
+ * CACHING IS DELIBERATELY AUSTERE: every byte behind /admin/ is
14
+ * authenticated content. A cached HTML page or JSON response on a shared or
15
+ * stolen phone is a data leak, so the precache list and the shouldCache
16
+ * predicate are the security boundary here, not an afterthought.
17
+ */
18
+
19
+ import { join } from "node:path";
20
+ // Locked design-token source — importing (not hardcoding) theme_color /
21
+ // background_color means token drift fails CI via the normal typecheck/test
22
+ // path rather than silently diverging from the rest of the brand system.
23
+ import tokens from "./theme-tokens.json";
24
+
25
+ // ─── Paths ──────────────────────────────────────────────────────────────────
26
+
27
+ /** Default committed location for the six PWA icon PNGs. Injectable via AdminUIDeps.pwaAssetsDir for tests. */
28
+ export const PWA_ASSETS_DIR = join(import.meta.dir, "..", "pwa-assets");
29
+
30
+ // ─── Icon metadata ────────────────────────────────────────────────────────────
31
+
32
+ interface PwaIconSpec {
33
+ filename: string;
34
+ size: number;
35
+ purpose: "any" | "maskable" | "apple-touch-icon";
36
+ }
37
+
38
+ /**
39
+ * The standard six-icon PWA set: 192/512 "any", 192/512 "maskable" (glyph
40
+ * confined to the ~40% safe zone), a 180x180 apple-touch-icon (opaque
41
+ * background — iOS composites transparent icons onto black), and a 32x32
42
+ * favicon-sized icon for browser chrome / bookmarks.
43
+ */
44
+ export const PWA_ICONS: readonly PwaIconSpec[] = [
45
+ { filename: "icon-192.png", size: 192, purpose: "any" },
46
+ { filename: "icon-512.png", size: 512, purpose: "any" },
47
+ { filename: "icon-maskable-192.png", size: 192, purpose: "maskable" },
48
+ { filename: "icon-maskable-512.png", size: 512, purpose: "maskable" },
49
+ { filename: "apple-touch-icon.png", size: 180, purpose: "apple-touch-icon" },
50
+ { filename: "favicon-32.png", size: 32, purpose: "any" },
51
+ ];
52
+
53
+ // ─── Manifest ─────────────────────────────────────────────────────────────────
54
+
55
+ interface WebAppManifest {
56
+ name: string;
57
+ short_name: string;
58
+ scope: string;
59
+ start_url: string;
60
+ display: string;
61
+ theme_color: string;
62
+ background_color: string;
63
+ icons: Array<{ src: string; sizes: string; type: string; purpose: string }>;
64
+ }
65
+
66
+ /** Fallback start_url — used whenever no (or no valid) page-specific start_url is available. */
67
+ const DEFAULT_START_URL = "/admin/chat";
68
+
69
+ /**
70
+ * Builds the web app manifest object. scope is always /admin/ (the
71
+ * browser-enforced backstop against ever installing outside the admin
72
+ * console). start_url defaults to /admin/chat per the original PWA shell
73
+ * spec, but callers (see GET /admin/manifest.webmanifest in admin-ui.ts) may
74
+ * pass the page the user was on when they triggered "Add to Home Screen" —
75
+ * PWA-1.1 — so the installed shortcut launches back into that page instead
76
+ * of always into chat. Callers are expected to have already run the value
77
+ * through sanitizeStartUrl(); this function trusts its input and does not
78
+ * re-validate, matching every existing no-arg call site. The manifest
79
+ * itself carries no secrets and is served unauthenticated (browsers fetch
80
+ * it with credentials:omit, so gating it behind a session would silently
81
+ * break install).
82
+ */
83
+ export function buildManifest(
84
+ startUrl: string = DEFAULT_START_URL,
85
+ ): WebAppManifest {
86
+ return {
87
+ name: "Shipwright Admin",
88
+ short_name: "Shipwright",
89
+ scope: "/admin/",
90
+ start_url: startUrl,
91
+ display: "standalone",
92
+ theme_color: tokens.color.brand.default,
93
+ background_color: tokens.color.bg.base,
94
+ icons: PWA_ICONS.filter((icon) => icon.purpose !== "apple-touch-icon").map(
95
+ (icon) => ({
96
+ src: `/admin/icons/${icon.filename}`,
97
+ sizes: `${icon.size}x${icon.size}`,
98
+ type: "image/png",
99
+ purpose: icon.purpose,
100
+ }),
101
+ ),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The PWA shell's own routes that a manifest start_url must never point at:
107
+ * they're either non-HTML assets (manifest/SW/icons), the offline shell
108
+ * page, or auth flows that end in a redirect rather than a page a user
109
+ * would want re-launched into. /admin/icons/ is a prefix (any file under
110
+ * it); the rest are exact matches. OAuth callback routes are matched by
111
+ * suffix below since each provider's path embeds a dynamic :id segment
112
+ * (e.g. /admin/agents/:id/connect-slack/callback) — see the route
113
+ * registrations in admin-ui.ts.
114
+ */
115
+ const NON_NAVIGABLE_EXACT_PATHS: readonly string[] = [
116
+ "/admin/manifest.webmanifest",
117
+ "/admin/sw.js",
118
+ "/admin/offline.html",
119
+ "/admin/login",
120
+ "/admin/logout",
121
+ "/admin/auth/callback",
122
+ "/admin/auth/okta/callback",
123
+ ];
124
+
125
+ /**
126
+ * Pure allowlist gate for the page a manifest's start_url may point at —
127
+ * PWA-1.1: renderPwaHeadTags rewrites the manifest link's href with
128
+ * `?start=<current pathname>` on every page load, and GET
129
+ * /admin/manifest.webmanifest feeds that raw, attacker-controlled query
130
+ * param straight through here before it ever reaches buildManifest().
131
+ * Same-origin, in-scope /admin/... paths pass through unchanged; anything
132
+ * else — external URLs, protocol-relative or javascript: values, path
133
+ * traversal, or one of the shell's own non-navigable asset/auth routes —
134
+ * falls back to DEFAULT_START_URL. Never throws: a malformed
135
+ * percent-encoding is treated as a rejection, not an error.
136
+ */
137
+ export function sanitizeStartUrl(path: string | null | undefined): string {
138
+ if (typeof path !== "string" || path.length === 0) return DEFAULT_START_URL;
139
+
140
+ let decoded: string;
141
+ try {
142
+ decoded = decodeURIComponent(path);
143
+ } catch {
144
+ return DEFAULT_START_URL; // malformed %-encoding — reject, don't throw
145
+ }
146
+
147
+ // Check both the raw and decoded forms so a %2e%2e-style encoded
148
+ // traversal (or an encoded non-/admin/ prefix) can't slip past a check
149
+ // that only looked at one representation.
150
+ for (const candidate of [path, decoded]) {
151
+ if (!candidate.startsWith("/admin/")) return DEFAULT_START_URL;
152
+ if (candidate.includes("..")) return DEFAULT_START_URL;
153
+ }
154
+
155
+ if (
156
+ NON_NAVIGABLE_EXACT_PATHS.includes(decoded) ||
157
+ decoded.startsWith("/admin/icons/") ||
158
+ decoded.endsWith("/callback")
159
+ ) {
160
+ return DEFAULT_START_URL;
161
+ }
162
+
163
+ return path; // already validated in-scope — pass through unchanged
164
+ }
165
+
166
+ // ─── Precache allowlist ───────────────────────────────────────────────────────
167
+
168
+ const OFFLINE_PAGE_PATH = "/admin/offline.html";
169
+
170
+ /**
171
+ * The EXACT set of URLs the service worker precaches: the offline fallback
172
+ * page and the six icons. Nothing else — no document navigation, no JSON
173
+ * route, nothing under /admin/chat/. Pure so it's independently
174
+ * unit-testable against the allowlist (acceptance criterion 3).
175
+ */
176
+ export function getPrecacheList(): string[] {
177
+ return [
178
+ OFFLINE_PAGE_PATH,
179
+ ...PWA_ICONS.map((icon) => `/admin/icons/${icon.filename}`),
180
+ ];
181
+ }
182
+
183
+ /**
184
+ * Pure predicate deciding whether a given request is allowed to be cached
185
+ * at all, independent of the precache list above — used by the service
186
+ * worker's fetch handler (if it ever caches on-the-fly) as a second,
187
+ * conservative gate. Only GET requests to a path already in the precache
188
+ * list are cacheable; everything else (any HTML document navigation, any
189
+ * .json route, anything under /admin/chat/, any non-GET) is refused.
190
+ */
191
+ export function shouldCachePwaRequest(path: string, method: string): boolean {
192
+ if (method.toUpperCase() !== "GET") return false;
193
+ return getPrecacheList().includes(path);
194
+ }
195
+
196
+ // ─── Service worker body ─────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Builds the service worker's JS source as a plain string — pure, no I/O.
200
+ * The cache name embeds `version` so a version bump busts any previously
201
+ * installed cache. Only precaches the given `precacheList` on install, and
202
+ * only ever serves cached responses (or falls through to network) for GET
203
+ * requests whose URL is in that exact list — this mirrors
204
+ * shouldCachePwaRequest so the deployed SW's real behavior matches what's
205
+ * unit-tested here.
206
+ */
207
+ export function buildServiceWorkerBody(
208
+ version: string,
209
+ precacheList: string[],
210
+ ): string {
211
+ const cacheName = `shipwright-admin-pwa-v${version}`;
212
+ const precacheJson = JSON.stringify(precacheList);
213
+
214
+ return `// GENERATED by admin/src/pwa.ts's buildServiceWorkerBody — do not edit by hand.
215
+ // Caching is deliberately austere: every byte behind /admin/ is authenticated
216
+ // content, so only the offline fallback page and the app icons are ever
217
+ // cached. No HTML document navigation, no .json route, nothing under
218
+ // /admin/chat/, and no non-GET request is ever cached.
219
+ const CACHE_NAME = ${JSON.stringify(cacheName)};
220
+ const PRECACHE_URLS = ${precacheJson};
221
+
222
+ self.addEventListener("install", (event) => {
223
+ event.waitUntil(
224
+ caches
225
+ .open(CACHE_NAME)
226
+ .then((cache) => cache.addAll(PRECACHE_URLS))
227
+ .then(() => self.skipWaiting()),
228
+ );
229
+ });
230
+
231
+ self.addEventListener("activate", (event) => {
232
+ event.waitUntil(
233
+ caches
234
+ .keys()
235
+ .then((keys) =>
236
+ Promise.all(
237
+ keys
238
+ .filter((key) => key !== CACHE_NAME)
239
+ .map((key) => caches.delete(key)),
240
+ ),
241
+ )
242
+ .then(() => self.clients.claim()),
243
+ );
244
+ });
245
+
246
+ self.addEventListener("fetch", (event) => {
247
+ const request = event.request;
248
+ if (request.method !== "GET") return;
249
+
250
+ const url = new URL(request.url);
251
+ if (!PRECACHE_URLS.includes(url.pathname)) return;
252
+
253
+ event.respondWith(
254
+ caches.match(request).then((cached) => cached || fetch(request)),
255
+ );
256
+ });
257
+
258
+ // Web Push (CFB-4.2). The payload is JSON {title, body, url}; the body is
259
+ // deliberately austere (see admin/src/push-content.ts) — it renders on a
260
+ // locked screen, so it never carries ids, repo names, paths, or costs.
261
+ self.addEventListener("push", (event) => {
262
+ let payload = { title: "Your agent replied", body: "", url: "/admin/chat" };
263
+ try {
264
+ if (event.data) payload = Object.assign(payload, event.data.json());
265
+ } catch {
266
+ // Malformed payload — fall back to the generic default.
267
+ }
268
+ event.waitUntil(
269
+ self.registration.showNotification(payload.title, {
270
+ body: payload.body || undefined,
271
+ data: { url: payload.url },
272
+ tag: payload.tag || "shipwright-agent-reply",
273
+ }),
274
+ );
275
+ });
276
+
277
+ // Focus an existing tab on the target thread, or open one.
278
+ self.addEventListener("notificationclick", (event) => {
279
+ event.notification.close();
280
+ const target =
281
+ (event.notification.data && event.notification.data.url) || "/admin/chat";
282
+ event.waitUntil(
283
+ self.clients
284
+ .matchAll({ type: "window", includeUncontrolled: true })
285
+ .then((wins) => {
286
+ for (const win of wins) {
287
+ if (win.url.indexOf(target) !== -1 && "focus" in win) {
288
+ return win.focus();
289
+ }
290
+ }
291
+ return self.clients.openWindow(target);
292
+ }),
293
+ );
294
+ });
295
+ `;
296
+ }
297
+
298
+ // ─── Offline fallback page ────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Renders the precached offline fallback page — deliberately minimal and
302
+ * self-contained (no admin-ui-layout.ts dependency, no live data): it's the
303
+ * one document navigation the service worker is allowed to serve from cache
304
+ * when the network is unavailable, so it must render correctly with zero
305
+ * external requests.
306
+ */
307
+ export function buildOfflinePageHtml(): string {
308
+ return `<!DOCTYPE html>
309
+ <html lang="en">
310
+ <head>
311
+ <meta charset="utf-8" />
312
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
313
+ <title>Offline — Shipwright Admin</title>
314
+ <style>
315
+ body {
316
+ margin: 0;
317
+ min-height: 100vh;
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: center;
321
+ background: ${tokens.color.bg.base};
322
+ color: ${tokens.color.text.body};
323
+ font-family: system-ui, sans-serif;
324
+ text-align: center;
325
+ padding: 24px;
326
+ box-sizing: border-box;
327
+ }
328
+ h1 { color: ${tokens.color.text.heading}; font-size: 1.5rem; margin-bottom: 8px; }
329
+ p { color: ${tokens.color.text.editorial}; margin: 0; }
330
+ </style>
331
+ </head>
332
+ <body>
333
+ <div>
334
+ <h1>You're offline</h1>
335
+ <p>Reconnect to continue using Shipwright Admin.</p>
336
+ </div>
337
+ </body>
338
+ </html>`;
339
+ }
340
+
341
+ // ─── Head tags (manifest link + SW registration) ──────────────────────────────
342
+
343
+ /**
344
+ * Renders the <link rel="manifest"> tag and the SW-registration <script>,
345
+ * gated on appBaseUrl being https — home-lab operators on plain HTTP would
346
+ * otherwise see a broken/no-op install prompt and a SW registration that
347
+ * silently fails (or, worse, throws in the console on every page load).
348
+ * Returns "" when the gate fails, so callers can splice this directly into
349
+ * <head> with no conditional of their own.
350
+ *
351
+ * PWA-1.1: the manifest <link> carries a stable id so an inline script,
352
+ * running immediately at parse time (not deferred to window 'load'), can
353
+ * rewrite its href to point at the *current* page before a user has a
354
+ * chance to trigger "Add to Home Screen". WebKit reads the manifest link's
355
+ * href fresh at the moment install is invoked — it's excluded from the
356
+ * service worker's cache predicate (shouldCachePwaRequest) specifically so
357
+ * this per-page rewrite is never served stale. GET
358
+ * /admin/manifest.webmanifest reads that ?start= query param back out,
359
+ * sanitizes it, and threads it into buildManifest().
360
+ */
361
+ export function renderPwaHeadTags(appBaseUrl: string): string {
362
+ if (!appBaseUrl?.startsWith("https://")) return "";
363
+
364
+ return [
365
+ '<link rel="manifest" href="/admin/manifest.webmanifest" id="pwa-manifest-link" />',
366
+ "<script>",
367
+ " document.getElementById('pwa-manifest-link').href =",
368
+ " '/admin/manifest.webmanifest?start=' + encodeURIComponent(location.pathname);",
369
+ "</script>",
370
+ '<link rel="apple-touch-icon" href="/admin/icons/apple-touch-icon.png" />',
371
+ `<meta name="theme-color" content="${tokens.color.brand.default}" />`,
372
+ "<script>",
373
+ " if ('serviceWorker' in navigator) {",
374
+ " window.addEventListener('load', () => {",
375
+ " navigator.serviceWorker.register('/admin/sw.js');",
376
+ " });",
377
+ " }",
378
+ "</script>",
379
+ ].join("\n ");
380
+ }