@app-vitals/agent-admin 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- 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
|
+
}
|