diff --git a/apps/gittensory-ui/public/manifest.webmanifest b/apps/gittensory-ui/public/manifest.webmanifest new file mode 100644 index 0000000000..cc70b17b1b --- /dev/null +++ b/apps/gittensory-ui/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Gittensory Control Panel", + "short_name": "Gittensory", + "start_url": "/app", + "scope": "/", + "display": "standalone", + "background_color": "#0a1714", + "theme_color": "#0a1714", + "description": "Control-panel workflows for maintainer and operator checks with opt-in browser notifications.", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 3975934a0f..76c235bcb6 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9793,6 +9793,145 @@ ] } }, + "/v1/app/notification-model": { + "get": { + "responses": { + "200": { + "description": "Opt-in notification model and PWA-readiness metadata for control-panel routes", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "generatedAt": { + "type": "string" + }, + "notificationModel": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "opt_in" + ] + }, + "defaultState": { + "type": "string", + "enum": [ + "disabled" + ] + }, + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "transport": { + "type": "string", + "enum": [ + "in_app", + "web_push" + ] + }, + "defaultEnabled": { + "type": "boolean" + }, + "requiresPermission": { + "type": "boolean" + }, + "purpose": { + "type": "string" + } + }, + "required": [ + "id", + "transport", + "defaultEnabled", + "purpose" + ] + } + }, + "privacyGuards": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallbackWhenUnavailable": { + "type": "string", + "enum": [ + "in_app_digest_only" + ] + } + }, + "required": [ + "mode", + "defaultState", + "channels", + "privacyGuards", + "fallbackWhenUnavailable" + ] + }, + "pwa": { + "type": "object", + "properties": { + "nativeDependency": { + "type": "boolean" + }, + "manifestPath": { + "type": "string" + }, + "serviceWorkerPath": { + "type": "string" + } + }, + "required": [ + "nativeDependency", + "manifestPath", + "serviceWorkerPath" + ] + }, + "mobileReadyRoutes": { + "type": "array", + "items": { + "type": "string" + } + }, + "nativeMobileFuture": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "generatedAt", + "notificationModel", + "pwa", + "mobileReadyRoutes", + "nativeMobileFuture" + ] + } + } + } + }, + "403": { + "description": "Role does not allow control-panel notification model access" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/repos": { "get": { "responses": { diff --git a/apps/gittensory-ui/public/sw.js b/apps/gittensory-ui/public/sw.js new file mode 100644 index 0000000000..23410936f1 --- /dev/null +++ b/apps/gittensory-ui/public/sw.js @@ -0,0 +1,12 @@ +self.addEventListener("install", (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(self.clients.openWindow("/app")); +}); diff --git a/apps/gittensory-ui/src/client.ts b/apps/gittensory-ui/src/client.ts index 1e54d7f49b..c69deace4a 100644 --- a/apps/gittensory-ui/src/client.ts +++ b/apps/gittensory-ui/src/client.ts @@ -8,3 +8,7 @@ React.startTransition(() => { React.createElement(React.StrictMode, null, React.createElement(StartClient)), ); }); + +if ("serviceWorker" in navigator && window.isSecureContext) { + void navigator.serviceWorker.register("/sw.js").catch(() => undefined); +} diff --git a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx new file mode 100644 index 0000000000..3cf2a3943a --- /dev/null +++ b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx @@ -0,0 +1,120 @@ +import { Bell, BellOff } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { StatusPill } from "./control-primitives"; +import { useApiResource } from "@/lib/api/use-api-resource"; +import { useLocalStorage } from "@/lib/use-local-storage"; + +type NotificationModelResponse = { + notificationModel: { + mode: "opt_in"; + defaultState: "disabled"; + channels: Array<{ + id: string; + transport: "in_app" | "web_push"; + defaultEnabled: boolean; + requiresPermission?: boolean; + purpose: string; + }>; + privacyGuards: string[]; + fallbackWhenUnavailable: string; + }; + pwa: { nativeDependency: boolean; manifestPath: string; serviceWorkerPath: string }; + mobileReadyRoutes: string[]; + nativeMobileFuture: string[]; +}; + +export function NotificationReadinessCard() { + const model = useApiResource( + "/v1/app/notification-model", + "Notification model", + ); + const [optIn, setOptIn] = useLocalStorage("gittensory_notification_opt_in", false); + const [busy, setBusy] = useState(false); + + const permission = typeof Notification === "undefined" ? "unsupported" : Notification.permission; + const canAskPermission = permission !== "unsupported" && permission !== "granted"; + const pushChannel = useMemo( + () => + model.status === "ready" + ? model.data.notificationModel.channels.find((channel) => channel.id === "browser_push") + : null, + [model], + ); + + async function enableNotifications() { + if (!canAskPermission || busy) return; + setBusy(true); + try { + const result = await Notification.requestPermission(); + if (result === "granted") setOptIn(true); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Notification readiness

+ + {optIn ? "opt-in enabled" : "opt-in required"} + +
+ {model.status === "ready" ? ( +
+

+ Delivery mode is {model.data.notificationModel.mode} and defaults to{" "} + {model.data.notificationModel.defaultState}. +

+
    + {model.data.notificationModel.channels.map((channel) => ( +
  • + · {channel.id}: {channel.purpose} +
  • + ))} +
+

+ Fallback: {model.data.notificationModel.fallbackWhenUnavailable}. Native app dependency:{" "} + {model.data.pwa.nativeDependency ? "yes" : "no"}. +

+
+ + + {pushChannel?.requiresPermission ? ( + + Browser permission: {permission} + + ) : null} +
+
    + {model.data.notificationModel.privacyGuards.map((guard) => ( +
  • · {guard}
  • + ))} +
+
+ ) : ( +

+ {model.status === "loading" + ? "Loading notification model…" + : "Notification model unavailable."} +

+ )} +
+ ); +} diff --git a/apps/gittensory-ui/src/routes/__root.tsx b/apps/gittensory-ui/src/routes/__root.tsx index 66982f7fa6..a8dfd861f4 100644 --- a/apps/gittensory-ui/src/routes/__root.tsx +++ b/apps/gittensory-ui/src/routes/__root.tsx @@ -99,6 +99,8 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( ], links: [ { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + { rel: "manifest", href: "/manifest.webmanifest" }, + { rel: "apple-touch-icon", href: "/favicon.svg" }, { rel: "stylesheet", href: appCss }, { rel: "preconnect", diff --git a/apps/gittensory-ui/src/routes/app.operator.tsx b/apps/gittensory-ui/src/routes/app.operator.tsx index 5027bc42d4..abe31018ab 100644 --- a/apps/gittensory-ui/src/routes/app.operator.tsx +++ b/apps/gittensory-ui/src/routes/app.operator.tsx @@ -6,6 +6,7 @@ import { Stat, StatusPill, } from "@/components/site/control-primitives"; +import { NotificationReadinessCard } from "@/components/site/notification-readiness-card"; import { StateBoundary } from "@/components/site/state-views"; import { useApiResource } from "@/lib/api/use-api-resource"; @@ -123,6 +124,7 @@ function OperatorDashboard() { ) : null} + ) : null} diff --git a/src/api/routes.ts b/src/api/routes.ts index ca974ff29b..254577ebb8 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -837,6 +837,49 @@ export function createApp() { }); }); + app.get("/v1/app/notification-model", async (c) => { + const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); + if (forbidden) return forbidden; + return c.json({ + generatedAt: nowIso(), + notificationModel: { + mode: "opt_in", + defaultState: "disabled", + channels: [ + { + id: "in_app_digest", + transport: "in_app", + defaultEnabled: true, + purpose: "Show control-panel digest and attention items after authenticated sign-in.", + }, + { + id: "browser_push", + transport: "web_push", + defaultEnabled: false, + requiresPermission: true, + purpose: "Optional browser push alerts for install health and drift warnings.", + }, + ], + privacyGuards: [ + "Never include wallets, hotkeys, payout/reward estimates, raw trust scores, or farming language.", + "Require authenticated browser session before showing private maintainer/operator notification details.", + "Keep delivery opt-in and user-controlled on each device.", + ], + fallbackWhenUnavailable: "in_app_digest_only", + }, + pwa: { + nativeDependency: false, + manifestPath: "/manifest.webmanifest", + serviceWorkerPath: "/sw.js", + }, + mobileReadyRoutes: ["/app", "/app/runs", "/app/repos", "/app/maintainer", "/app/operator"], + nativeMobileFuture: [ + "OS-level background sync for alerts when browser is closed.", + "Per-device biometric re-auth and secure lock-screen notification handling.", + ], + }); + }); + app.get("/v1/app/analytics/mcp-compatibility", async (c) => { const forbidden = await requireAppRole(c, ["operator"]); if (forbidden) return forbidden; diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 141a92a475..b73556335d 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -254,6 +254,45 @@ export function buildOpenApiSpec() { 404: { description: "Installation health not found" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/app/notification-model", + responses: { + 200: { + description: "Opt-in notification model and PWA-readiness metadata for control-panel routes", + content: { + "application/json": { + schema: z.object({ + generatedAt: z.string(), + notificationModel: z.object({ + mode: z.literal("opt_in"), + defaultState: z.literal("disabled"), + channels: z.array( + z.object({ + id: z.string(), + transport: z.enum(["in_app", "web_push"]), + defaultEnabled: z.boolean(), + requiresPermission: z.boolean().optional(), + purpose: z.string(), + }), + ), + privacyGuards: z.array(z.string()), + fallbackWhenUnavailable: z.literal("in_app_digest_only"), + }), + pwa: z.object({ + nativeDependency: z.boolean(), + manifestPath: z.string(), + serviceWorkerPath: z.string(), + }), + mobileReadyRoutes: z.array(z.string()), + nativeMobileFuture: z.array(z.string()), + }), + }, + }, + }, + 403: { description: "Role does not allow control-panel notification model access" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos", diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 3447c0a2ce..1add1ba879 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1273,6 +1273,7 @@ describe("api routes", () => { evidence: { ownedInstalledRepos: 1, accountInstallations: 1, operator: false }, }); expect((await app.request("/v1/app/maintainer-dashboard", { headers: ownerHeaders }, ownerEnv)).status).toBe(200); + expect((await app.request("/v1/app/notification-model", { headers: ownerHeaders }, ownerEnv)).status).toBe(200); expect((await app.request("/v1/app/operator-dashboard", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); expect((await app.request("/v1/app/analytics/daily-rollups", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); expect((await app.request("/v1/app/analytics/mcp-compatibility", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); @@ -1449,6 +1450,33 @@ describe("api routes", () => { weeklyReport: expect.arrayContaining([expect.stringContaining("registered repo")]), }); + const notificationModel = await app.request("/v1/app/notification-model", { headers: apiHeaders(env) }, env); + expect(notificationModel.status).toBe(200); + const notificationBody = (await notificationModel.json()) as Record; + expect(notificationBody).toMatchObject({ + notificationModel: { + mode: "opt_in", + defaultState: "disabled", + fallbackWhenUnavailable: "in_app_digest_only", + channels: expect.arrayContaining([ + expect.objectContaining({ id: "in_app_digest", defaultEnabled: true }), + expect.objectContaining({ id: "browser_push", defaultEnabled: false, requiresPermission: true }), + ]), + privacyGuards: expect.arrayContaining([ + expect.stringMatching(/wallets|hotkeys|payout\/reward/i), + expect.stringMatching(/authenticated browser session/i), + ]), + }, + pwa: { + nativeDependency: false, + manifestPath: "/manifest.webmanifest", + serviceWorkerPath: "/sw.js", + }, + mobileReadyRoutes: expect.arrayContaining(["/app/operator", "/app/maintainer"]), + nativeMobileFuture: expect.any(Array), + }); + expect(JSON.stringify(notificationBody)).toMatch(/wallets|hotkeys|payout\/reward estimates|raw trust scores|farming language/i); + const commands = await app.request("/v1/app/commands", { headers: apiHeaders(env) }, env); expect(commands.status).toBe(200); await expect(commands.json()).resolves.toMatchObject({