From 19d303937924040f351f3060d5c97a100e941240 Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:07:34 +0200 Subject: [PATCH 1/3] feat: control panel notification ready --- .../gittensory-ui/public/manifest.webmanifest | 18 +++ apps/gittensory-ui/public/openapi.json | 139 ++++++++++++++++++ apps/gittensory-ui/public/sw.js | 12 ++ apps/gittensory-ui/src/client.ts | 4 + .../site/notification-readiness-card.tsx | 103 +++++++++++++ apps/gittensory-ui/src/routes/__root.tsx | 2 + .../gittensory-ui/src/routes/app.operator.tsx | 2 + src/api/routes.ts | 43 ++++++ src/openapi/spec.ts | 39 +++++ test/integration/api.test.ts | 29 ++++ 10 files changed, 391 insertions(+) create mode 100644 apps/gittensory-ui/public/manifest.webmanifest create mode 100644 apps/gittensory-ui/public/sw.js create mode 100644 apps/gittensory-ui/src/components/site/notification-readiness-card.tsx 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 91a3fe545c..e05d854fbd 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9673,6 +9673,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..cc8a0fb677 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx @@ -0,0 +1,103 @@ +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 51a5feb3e8..bfeba1868b 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"; @@ -102,6 +103,7 @@ function OperatorDashboard() { + ) : null} diff --git a/src/api/routes.ts b/src/api/routes.ts index f67eb74c89..e15ead0e7b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -791,6 +791,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/commands", async (c) => c.json({ generatedAt: nowIso(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 5c25d209f5..8df9b21e63 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 ae6c047a0b..ae10b499f3 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1094,6 +1094,7 @@ describe("api routes", () => { expect(unknownOverview.status).toBe(200); await expect(unknownOverview.json()).resolves.toMatchObject({ roleSummary: { roles: [], onboarding: { status: "needs_setup" } } }); expect((await app.request("/v1/app/operator-dashboard", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); + expect((await app.request("/v1/app/notification-model", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect((await app.request("/v1/contributors/new-user/decision-pack", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect((await app.request("/v1/auth/extension/session", { method: "POST", headers: unknownHeaders }, unknownEnv)).status).toBe(403); @@ -1117,6 +1118,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); const ownerExtensionSession = await app.request("/v1/auth/extension/session", { method: "POST", headers: ownerHeaders }, ownerEnv); expect(ownerExtensionSession.status).toBe(201); @@ -1285,6 +1287,33 @@ describe("api routes", () => { weeklyReport: expect.arrayContaining([expect.stringContaining("registered repos")]), }); + 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({ From ebffa11e7e9027f5160ead3149d7c369048a1b13 Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:17:56 +0200 Subject: [PATCH 2/3] fix: CI test failed --- .../site/notification-readiness-card.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx index cc8a0fb677..3cf2a3943a 100644 --- a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx +++ b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx @@ -9,7 +9,13 @@ type NotificationModelResponse = { notificationModel: { mode: "opt_in"; defaultState: "disabled"; - channels: Array<{ id: string; transport: "in_app" | "web_push"; defaultEnabled: boolean; requiresPermission?: boolean; purpose: string }>; + channels: Array<{ + id: string; + transport: "in_app" | "web_push"; + defaultEnabled: boolean; + requiresPermission?: boolean; + purpose: string; + }>; privacyGuards: string[]; fallbackWhenUnavailable: string; }; @@ -29,7 +35,10 @@ export function NotificationReadinessCard() { 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.status === "ready" + ? model.data.notificationModel.channels.find((channel) => channel.id === "browser_push") + : null, [model], ); @@ -48,20 +57,26 @@ export function NotificationReadinessCard() {

Notification readiness

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

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

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

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

From 4ddb68b799af7f12ace37b623cbf5ba7efb79e0b Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:18:00 +0200 Subject: [PATCH 3/3] fix: ci route failed --- src/api/routes.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/api/routes.ts b/src/api/routes.ts index 79dcf2b17c..4bcaa56b7b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -819,6 +819,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;