Skip to content

feat: v4.0.0 — session persistence, PWA push, UI overhaul, Azure hardening#109

Merged
devartifex merged 29 commits into
masterfrom
feature/session-persistence-security-pwa
Mar 22, 2026
Merged

feat: v4.0.0 — session persistence, PWA push, UI overhaul, Azure hardening#109
devartifex merged 29 commits into
masterfrom
feature/session-persistence-security-pwa

Conversation

@devartifex

@devartifex devartifex commented Mar 22, 2026

Copy link
Copy Markdown
Owner

Closes #108

Summary

Major release (v4.0.0) introducing session persistence across browser close/refresh, PWA with push notifications (including iOS), full UI overhaul following ChatGPT/Claude patterns, new branding, Azure infrastructure hardening, and numerous bug fixes. 88 files changed, 5818 insertions, 315 deletions.

Changes

🔁 Session Persistence

  • Cold resume: Chat history persisted server-side per user/tab via ChatStateStore (atomic writes, 1000-msg cap); restored on browser reopen without delay
  • Warm reconnect: Full message history + pending permission prompts restored from session pool buffer
  • Smart session strategy: Server pre-loads persisted state and embeds sdkSessionId in the connected message — client instantly resumes existing SDK session instead of creating a blank new one on every refresh
  • No empty sessions: Filter out SDK-internal sessions with no content (/home/node cwd, no messages)
  • Loading skeleton: Shimmer skeleton animation during session restore — no flash of empty chat, fix for stuck loading state
  • Session watcher: fs.watch on session-state directory with 100ms debounce broadcasts CLI ↔ Browser state changes to all connected WebSocket clients

📲 PWA & Push Notifications

  • Service worker (sw.js) with precaching, push handler, and notification click routing
  • manifest.json with icon variants (192, 512, maskable) and display: standalone
  • VAPID key generation script (scripts/generate-vapid-keys.mjs)
  • Push subscription endpoints (/api/push/subscribe, /api/push/unsubscribe, /api/push/vapid-key)
  • Server-side subscription store with 10-sub cap per user, auto-cleanup of expired subs
  • iOS 16.4+ PWA support (Add to Home Screen + iOS meta tags)
  • New env vars: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, CHAT_STATE_PATH, PUSH_STORE_PATH

🎨 UI

  • Chat bubble alignment: User messages right-aligned, Copilot left-aligned — universal messaging pattern
  • Banner redesign: Logo + "Hello, {username}" greeting (ChatGPT/Claude pattern), no version number clutter
  • TopBar redesign: Shows session title when active, "New chat" otherwise — logo removed from topbar (lives in Banner)
  • Attachment menu: Enhanced positioning and visibility in ChatInput component; file attachment preview moved outside .input-container to fix z-index/clipping
  • Skeleton shimmer: Consistent loading animation during session restore (matching SessionsSheet pattern)

🖼️ Branding

  • New SVG logo (logo-copilot-unleashed.svg) and background-free variant (logo-no-bg.svg) applied app-wide
  • favicon.svg added for crisp browser tab icon
  • PWA icons generated (icon-192.png, icon-512.png, icon-maskable-512.png)
  • Old PNG logo files removed

🔐 Infrastructure

  • ACR access control: deployerIpAddress Bicep parameter allows local machine/CI runner to push images while keeping ACR private
  • Auto-detect IP: Preprovision hook (infra/hooks/preprovision.sh) auto-detects deployer IP address
  • VNet: Subnets for Container Apps environment and private endpoints
  • Key Vault: Stores VAPID secrets with managed identity access
  • Azure Files: Persistent volume mounts for /data (chat state, push subs) and /home/node/.copilot
  • Lazy-init singletons: push-singleton.ts and chat-state-singleton.ts avoid config access at build time

🐛 Fixes

  • Concurrent permission prompts: Map<requestId, resolver> replaces single-slot resolver — multiple tool approvals no longer clobber each other
  • wireSessionEvents: Now receives userLogin/tabId so assistant messages are persisted correctly during resumed sessions
  • Session watcher: fs.watch-based CLI ↔ Browser autosync with 100ms debounce
  • Lazy-init singletons: Fix npm run build crash when config is accessed at import time
  • Config validation: Fail-fast on missing required env vars, no build-time side effects

📝 Docs

  • ARCHITECTURE.md: Updated with session persistence, push notifications, session watcher, and component inventory
  • SECURITY.md: Added SSRF prevention, push notification security, atomic writes
  • README.md: Updated feature list, env vars table, deployment instructions
  • copilot-instructions.md: Refreshed project structure and conventions

Testing

Check Result
Unit tests ✅ 380 passed (33 test files)
Build ✅ Clean (npm run build)
Type check ✅ 0 errors, 0 warnings (npm run check)

devartifex and others added 18 commits March 22, 2026 00:21
- Session persistence: ChatStateStore persists chat history to disk, cold resume on reconnect
- Tab ID switched to localStorage for browser-close survival
- Session watcher: fs.watch with debounce for CLI↔browser autosync
- PWA: manifest.json, service worker with precaching + push handling
- Push notifications: web-push with VAPID, subscription store, server-side push when WS disconnected
- Security: auth guards on all endpoints, token revalidation, CSP hardening, CSRF, SSRF protection
- Azure: VNet with 3 subnets, Key Vault, Premium ACR, storage hardening, diagnostics
- Docker: new data dirs, VAPID env vars, volume mount updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t-implementation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
push-singleton and chat-state-singleton were eagerly calling config.*
at module import time, causing npm run build to fail with
'Missing required env var: SESSION_SECRET' during SvelteKit's
post-build analysis step.

Fixed by wrapping both singletons in Proxy with lazy initialization —
config is only accessed on first actual method call at runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add new logo SVG (logo-copilot unleashed.svg → logo.svg) with dark
  gradient brackets, spark effect, and sparkle stars on #09090b background
- Regenerate all PNG icons (favicon, 192, 512, maskable) from new SVG
- Update TopBar.svelte and DeviceFlowLogin.svelte: /img/logo.png → /img/logo.svg
- Update app.html: add SVG favicon link (rel=icon svg+xml) + apple-touch-icon uses icon-192
- Update manifest.json: add SVG icon entry (sizes=any) as first icon
- Update generate-pwa-icons.mjs: use logo.svg as source instead of favicon.png

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create logo-no-bg.svg: same graphics as logo.svg but no dark background
  rect or border — transparent background for use in header and login page
- TopBar.svelte + DeviceFlowLogin.svelte: switch to logo-no-bg.svg
- Add static/favicon.svg (full icon with background) for browser favicon
- app.html: point SVG favicon link to /favicon.svg (root-level, conventional)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove original 'logo-copilot unleashed.svg' (now logo.svg/logo-no-bg.svg)
- Remove logo.png (replaced by SVG logos)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two bugs that caused conversation history to not show after browser reopen:

1. Warm reconnect (pool entry still alive within TTL window) sent
   session_reconnected without any message history. Now loads persisted
   state and sends cold_resume so the client repopulates messages.

2. wireSessionEvents in resume-session.ts was called without userLogin
   and tabId, so assistant messages from resumed sessions were never
   persisted to ChatStateStore. Now passes both parameters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…index

Bug 1 — Permission queue:
Previously, every layer used a single-slot for permission requests.
When the SDK fired multiple concurrent permission requests, each new
one overwrote the previous — losing its resolve callback forever.

Now uses Map<requestId, resolver> at every layer:
- PoolEntry: permissionResolves Map + pendingPermissionPrompts Map
- makePermissionHandler: adds to map instead of overwriting
- handlePermissionResponse: looks up by requestId from client
- Reconnect: re-sends ALL pending permission prompts
- Client chat store: pendingPermissions array (was single | null)
- Page template: {#each} over all pending permissions

Bug 2 — File attachment preview clipped:
The .file-preview-row was inside .input-container which has
overflow: hidden (for border-radius clipping). Moved the file
preview row outside and above .input-container so it renders in
the normal .input-area flow without being clipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
User messages now appear right-aligned with border-right, mirroring
universal chat UX (WhatsApp/iMessage). Assistant messages remain
left-aligned with border-left. No color change — layout alone
communicates sender identity clearly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Copilot SDK creates a session entry for every connection,
including ones where no messages were ever sent. These appear in
the session list as empty entries labelled '/home/node'.

Fix: after merging SDK + filesystem sessions, discard entries with
no title, no checkpoints, no plan, and a cwd that is just the
container home dir. Also strip cwd='/home/node' from enriched
metadata so it never surfaces in the UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… creating new one

Previously, every browser refresh sent new_session unconditionally,
creating a blank SDK session even when a previous session existed.
This caused the session list to fill up with empty '/home/node' entries.

New flow on 'connected':
- Wait up to 400ms for a cold_resume message from the server
- If cold_resume arrives with sdkSessionId → resume_session (no new session created)
- If cold_resume has no sdkSessionId (history but no live SDK session) → new_session
- If timeout fires (no cold_resume) → new_session as before

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…cted message

Server now pre-loads persisted chat state before sending 'connected',
embedding sdkSessionId and hasPersistedState directly in the message.
Client decides instantly on receipt: resume_session if sdkSessionId
exists, requestNewSession otherwise. No timer, no delay, no race.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Show a pulsing logo + 'Restoring session…' text while the server
loads persisted state and the SDK session resumes. The empty chat
(Banner) is never shown during restore — only after confirmation
that there is no previous session. sessionLoading starts true and
clears on cold_resume / session_created / session_resumed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace custom logo pulse animation with the app's standard skeleton
shimmer bars (same pattern as SessionsSheet loading). Fixes:
- sessionLoading now also clears on session_reconnected
- If resume fails (error message while loading), auto-fallback to
  requestNewSession() instead of staying stuck forever

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Banner (empty chat):
- Logo centered + 'Hello, {username}' greeting (like Claude/Gemini)
- Simplified hints on one line, no version number
- Clean, focused, inviting

TopBar:
- Shows 'New chat' text when no session title (no redundant logo)
- Session title shown when active session exists
- Removed brand-group/logo/gradient from top bar (logo lives in Banner)

Login page: unchanged (already well-designed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread scripts/generate-vapid-keys.mjs Fixed
Comment thread scripts/generate-vapid-keys.mjs Fixed
Comment thread scripts/generate-vapid-keys.mjs Fixed
Comment thread scripts/generate-vapid-keys.mjs Fixed
…eprovision

- Add deployerIpAddress to main.parameters.json so azd passes it to Bicep
- Add preprovision hook to auto-detect current public IP via ipify.org
- This ensures ACR IP allowlist is always set to the deployer's current IP

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
devartifex and others added 3 commits March 22, 2026 03:02
Merge 'Persistent sessions' + 'Session persistence' into one entry.
Merge 'CLI ↔ Browser sync' + 'CLI ↔ Browser autosync' into one entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The dev:local script bypassed server.js (express-session + WebSocket),
so it never worked properly. Keep only npm run dev (Docker Compose).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

v4.0.0 release integrating server-side session persistence (cold resume), PWA + Web Push notifications, a chat UI overhaul, and Azure infrastructure hardening across the Copilot Unleashed SvelteKit/WebSocket architecture.

Changes:

  • Added persisted chat state + session watcher autosync to restore conversations across refresh/browser close and sync CLI ↔ browser sessions.
  • Introduced PWA assets (manifest + service worker) and authenticated push-subscription APIs with server-side delivery via web-push.
  • Hardened auth/security (periodic token revalidation, stricter CSP/CSRF) and expanded Azure IaC (VNet, private endpoints, Key Vault, hardened ACR/Storage, diagnostics).

Reviewed changes

Copilot reviewed 80 out of 89 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
static/sw.js Service worker: precache, fetch strategy, push + notification handlers.
static/manifest.json PWA manifest (name, colors, icons).
static/img/logo.svg New SVG logo asset.
static/img/logo-no-bg.svg Background-free logo variant for UI usage.
static/favicon.svg New SVG favicon.
static/favicon.png New PNG favicon (generated).
src/app.html PWA meta + icons + manifest link.
src/routes/+layout.svelte Registers service worker on mount.
src/lib/utils/sw-register.ts SW registration helper.
src/lib/utils/push-notifications.ts Client push subscription helpers (VAPID fetch + subscribe/unsubscribe).
src/lib/utils/notifications.ts Hooks push subscription into notification flow.
src/routes/+page.svelte Session resume flow + skeleton loading + multi-permission prompts.
src/lib/components/TopBar.svelte Top bar redesign + logo change + mobile brand hiding.
src/lib/components/Banner.svelte Banner redesign + username greeting.
src/lib/components/DeviceFlowLogin.svelte Branding/logo update.
src/lib/components/ChatMessage.svelte User bubble alignment + border changes.
src/lib/components/ChatInput.svelte Attachment menu positioning + file preview layout adjustments.
src/lib/types/index.ts New WS message types (cold resume, sessions_changed, clear_chat).
src/lib/stores/ws.svelte.ts Tab ID persistence change to support cold resume.
src/lib/stores/chat.svelte.ts Cold resume handling + persisted clear_chat + multi-permission queue.
src/lib/stores/chat.test.ts Updated tests for multi-permission changes.
src/lib/server/config.ts New env vars for chat state + push store + VAPID configuration.
src/lib/server/chat-state-store.ts Disk-backed chat persistence with atomic writes + message cap.
src/lib/server/chat-state-store.test.ts Unit tests for chat state store behavior.
src/lib/server/chat-state-singleton.ts Lazy-init singleton wrapper for chat state store.
src/lib/server/push/subscription-store.ts Disk-backed per-user push subscription storage.
src/lib/server/push/subscription-store.test.ts Unit tests for subscription store behavior.
src/lib/server/push/sender.ts Web Push sender wrapper with auto-cleanup of expired subs.
src/lib/server/push/index.ts Push module exports.
src/lib/server/push-singleton.ts Lazy-init singleton wrapper for subscription store.
src/routes/api/push/vapid-key/+server.ts Authenticated public VAPID key endpoint.
src/routes/api/push/subscribe/+server.ts Authenticated push subscription registration endpoint.
src/routes/api/push/unsubscribe/+server.ts Authenticated push unsubscribe endpoint.
src/routes/api/version/+server.ts Secured version endpoint via checkAuth.
src/routes/api/version/server.test.ts Tests for auth-gated version endpoint.
src/routes/api/skills/+server.ts Secured skills listing endpoint via checkAuth.
src/routes/api/client-error/+server.ts Secured client error reporting endpoint via checkAuth.
src/routes/api/client-error/server.test.ts Tests for auth-gated client error reporting.
src/lib/server/auth/guard.ts Added periodic token revalidation helper.
src/lib/server/auth/guard.test.ts Unit tests for token revalidation behavior.
src/hooks.server.ts Adds token revalidation hook + CSP/CSRF hardening + init side effects.
src/hooks.server.test.ts Expanded hook tests for new CSRF + token revalidation behavior.
src/lib/server/init.ts Server-side initialization (session watcher + shutdown hooks).
src/lib/server/session-watcher.ts fs.watch-based session-state watcher with debounce.
src/lib/server/session-watcher.test.ts Unit tests for session watcher behavior.
src/lib/server/copilot/session.ts SSRF hardening: redirect: 'manual' for tool fetches.
src/lib/server/ws/constants.ts Adds clear_chat to WS message whitelist.
src/lib/server/ws/handler.ts Loads persisted chat state and sends connected + cold_resume.
src/lib/server/ws/session-pool.ts Adds sdkSessionId/model/mode + concurrent permission tracking + broadcast helper.
src/lib/server/ws/session-pool.test.ts Tests updated for new pool entry shape.
src/lib/server/ws/session-events.ts Persists assistant/error messages + triggers push notifications when disconnected.
src/lib/server/ws/permissions.ts Push notifications for permission/user-input prompts when disconnected.
src/lib/server/ws/message-handlers/chat.ts Persists user messages to chat state store.
src/lib/server/ws/message-handlers/index.ts Adds clear_chat handler to delete persisted chat state.
src/lib/server/ws/message-handlers/interactive.ts Concurrent permission response handling via resolver map.
src/lib/server/ws/message-handlers/mode-model.ts Abort now denies all pending permission resolvers.
src/lib/server/ws/message-handlers/new-session.ts Clears persisted state on new session + stores resume metadata.
src/lib/server/ws/message-handlers/resume-session.ts Wires new handlers with userLogin/tabId for persistence + push.
src/lib/server/ws/message-handlers/session-management.ts Filters SDK-internal empty sessions from session list.
server.js Requires SESSION_SECRET in production.
docker-compose.yml Adds chat/push store paths + VAPID env vars.
Dockerfile Creates persistent directories for chat state + push subs + copilot home.
package.json Bumps version to 4.0.0; adds web-push, @types/web-push, sharp.
scripts/set-deployer-ip.sh Helper to set deployer IP for ACR access.
scripts/generate-vapid-keys.mjs Generates VAPID keys for push.
scripts/generate-pwa-icons.mjs Generates PWA icon assets using sharp.
azure.yaml Adds preprovision hook to auto-detect deployer IP.
infra/abbreviations.json Adds keyVault and vnet abbreviations.
infra/main.parameters.json Adds deployer IP parameter wiring.
infra/main.bicep Adds VNet, Storage, Key Vault, Diagnostics modules and wiring.
infra/modules/vnet.bicep New VNet + subnets + NSGs module.
infra/modules/container-registry.bicep Premium ACR + private endpoint + optional deployer IP allow rule.
infra/modules/storage.bicep Premium Files + NFS share + private endpoint + VNet rules.
infra/modules/key-vault.bicep New Key Vault module with RBAC + optional private endpoint + secret bootstrap.
infra/modules/diagnostics.bicep Diagnostic settings for ACR/Storage/KV to Log Analytics.
infra/modules/container-apps.bicep Adds VNet integration, Key Vault refs, volumes, and push env vars.
infra/modules/container-apps.json Generated ARM template for container apps module.
docs/ARCHITECTURE.md Documents persistence, autosync watcher, PWA/push, and infra changes.
README.md Documents new features, env vars, Azure hardening, deployer IP, VAPID setup.
SECURITY.md Documents CSRF/CSP hardening, SSRF redirect blocking, push security, infra security.
CONTRIBUTING.md Updates dev workflow instructions.
.github/copilot-instructions.md Updates repo structure/conventions and new features.
.env.example Adds VAPID + data path env var examples.

Comment thread static/sw.js
Comment on lines +110 to +114
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
self.registration.pushManager.subscribe(event.oldSubscription?.options || {
userVisibleOnly: true,
})

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushsubscriptionchange re-subscribes without an applicationServerKey. Most browsers require the same VAPID key used originally, so this will likely throw and break push after subscription rotation. Persist/fetch the VAPID public key in the service worker and include applicationServerKey when calling subscribe().

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +16
const body = await request.json();

// Validate subscription object
if (!body.endpoint || !body.keys?.p256dh || !body.keys?.auth) {
return json({ error: 'Invalid subscription' }, { status: 400 });

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.json() can throw on invalid JSON; right now that would likely surface as a 500. Wrap JSON parsing in try/catch and return a 400 with a JSON error payload (consistent with other API routes in this repo).

Copilot uses AI. Check for mistakes.
Comment thread infra/modules/storage.bicep Outdated
Comment on lines +12 to +15
const body = await request.json();
if (!body.endpoint) {
return json({ error: 'Endpoint required' }, { status: 400 });
}

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.json() can throw on invalid JSON; right now that would surface as a 500. Wrap JSON parsing in try/catch and return a 400 with a JSON error payload.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +377 to 378
expect(store.pendingPermissions).toEqual([]); expect(notifyMock).toHaveBeenCalledWith('Copilot is asking you something', {
body: 'Need approval?',

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two expect(...) statements on the same line, which is a syntax error and will prevent this test file from compiling/running. Split them into separate statements on separate lines.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +4 to +6
vi.mock('$lib/server/auth/guard', () => ({
checkAuth: vi.fn(),
}));

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mocks $lib/server/auth/guard (no .js), but the route imports $lib/server/auth/guard.js. Vitest matches mocks by the exact specifier string, so the mock may not apply. Use the same import/mocked specifier as the route to avoid exercising the real auth guard in unit tests.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines 1 to 4
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { checkAuth } from '$lib/server/auth/guard.js';
import { logSecurity } from '$lib/server/security-log';

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json is imported but never used in this file. Either use json(...) for the 401 response (recommended to set content-type) or remove the unused import to avoid dead code.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +4 to +6
vi.mock('$lib/server/auth/guard', () => ({
checkAuth: vi.fn(),
}));

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mocks $lib/server/auth/guard (no .js), but the route imports $lib/server/auth/guard.js. Vitest matches mocks by the exact specifier string, so the mock may not apply. Use the same import/mocked specifier as the route.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +49 to +52
// Also set up push subscription for when the browser is closed
if (isPushSupported()) {
subscribeToPush().catch(() => {});
}

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notify() triggers subscribeToPush() when permission is first granted. This can cause unexpected push registration as a side-effect of showing a notification, and can repeat network calls if notify() is triggered multiple times. Consider making push subscription explicit and/or idempotent (check for an existing subscription before subscribing).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +25 to +29
const requestId = typeof msg.requestId === 'string' ? msg.requestId : '';
const permResolve = requestId
? connectionEntry.permissionResolves.get(requestId)
: connectionEntry.permissionResolves.values().next().value ?? null;

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If requestId is missing/invalid, this falls back to resolving the first pending permission but does not remove that entry from permissionResolves/pendingPermissionPrompts, which can leak entries and cause duplicate resolves. Even if current clients always send requestId, it’s safer to remove the resolved entry in the fallback path.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #111, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #112, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #113, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #114, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #115, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #116, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

@devartifex I've opened a new pull request, #117, to work on those changes. Once the pull request is ready, I'll request review from you.

devartifex and others added 2 commits March 22, 2026 03:19
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ensitive information

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
console.log('');
console.log('Add these to your environment variables:');
console.log('');
console.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to vapidKeys
as clear text.

Copilot Autofix

AI 3 months ago

General fix: avoid writing sensitive key material directly to logs/stdout, or at least minimize and gate it. Provide alternative ways to obtain/store the keys (e.g., structured output to a file) and clearly separate public from private data. If secrets must be displayed, require explicit user opt‑in and warn about implications.

Best fix here without changing functionality too much:

  • Treat the public key as safe to print (VAPID public keys are not secret), so we can leave VAPID_PUBLIC_KEY logs intact.
  • Make handling of the private key more careful:
    • Do not print the raw private key even when --show-private is passed; instead, prompt the user to use an explicit additional flag (e.g., --confirm-stdout) or better, to redirect output into a file.
    • Since we must not change overall behavior too much, a lighter change is to:
      • Keep --show-private but add a strong warning that stdout may be logged and suggest redirecting to a secure file.
      • Optionally, when --show-private is not set, still avoid including any part of the private key in logs (already true) and just confirm its generation.
  • Additionally, for docker-compose output, similarly avoid printing the private key directly unless --show-private is explicitly specified, which is already what the code does; we can augment the comments to highlight that secrets should be managed securely.

Given we’re only allowed to change this file and not the surrounding tooling, the minimal concrete change that improves security and still respects intent is: when showPrivate is true, print a clear warning before printing the private key, telling the user that this is sensitive and may be captured by logs, and suggesting that they instead redirect output to a file or paste it directly into a vault. That way, CodeQL’s concern (clear‑text logging of sensitive data) is mitigated by explicit user awareness and a clear opt‑in path. We will update only the if (showPrivate) branches at lines 23–28 and 37–42 to add warnings and separation lines around the private key output; no new imports or external libraries are required.

Suggested changeset 1
scripts/generate-vapid-keys.mjs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/generate-vapid-keys.mjs b/scripts/generate-vapid-keys.mjs
--- a/scripts/generate-vapid-keys.mjs
+++ b/scripts/generate-vapid-keys.mjs
@@ -21,6 +21,11 @@
 console.log(`VAPID_SUBJECT=mailto:your-email@example.com`);
 
 if (showPrivate) {
+  console.log('');
+  console.log('# WARNING: The following VAPID_PRIVATE_KEY is sensitive.');
+  console.log('# It may be captured in shell history, CI logs, or terminal logs.');
+  console.log('# Prefer copying it directly into your secret store (e.g. .env, Key Vault)');
+  console.log('# and avoid committing or storing this output in version control.');
   console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
 } else {
   console.log('# VAPID_PRIVATE_KEY was generated but is hidden by default.');
@@ -35,6 +40,8 @@
 console.log('    - VAPID_SUBJECT=mailto:your-email@example.com');
 
 if (showPrivate) {
+  console.log('    # WARNING: VAPID_PRIVATE_KEY is sensitive; avoid committing docker-compose.yml with this value.');
+  console.log('    # Store it in a secure secret manager or environment configuration.');
   console.log(`    - VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
 } else {
   console.log('    # VAPID_PRIVATE_KEY was generated but is hidden by default.');
EOF
@@ -21,6 +21,11 @@
console.log(`VAPID_SUBJECT=mailto:your-email@example.com`);

if (showPrivate) {
console.log('');
console.log('# WARNING: The following VAPID_PRIVATE_KEY is sensitive.');
console.log('# It may be captured in shell history, CI logs, or terminal logs.');
console.log('# Prefer copying it directly into your secret store (e.g. .env, Key Vault)');
console.log('# and avoid committing or storing this output in version control.');
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
} else {
console.log('# VAPID_PRIVATE_KEY was generated but is hidden by default.');
@@ -35,6 +40,8 @@
console.log(' - VAPID_SUBJECT=mailto:your-email@example.com');

if (showPrivate) {
console.log(' # WARNING: VAPID_PRIVATE_KEY is sensitive; avoid committing docker-compose.yml with this value.');
console.log(' # Store it in a secure secret manager or environment configuration.');
console.log(` - VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
} else {
console.log(' # VAPID_PRIVATE_KEY was generated but is hidden by default.');
Copilot is powered by AI and may make mistakes. Always verify output.
@devartifex devartifex committed this autofix suggestion 3 months ago.
console.log(`VAPID_SUBJECT=mailto:your-email@example.com`);

if (showPrivate) {
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to vapidKeys
as clear text.

Copilot Autofix

AI 3 months ago

In general, the fix is to avoid printing the private key to a generic log/console channel, and instead expose it in a way that reduces unintended persistence (logs, CI output, etc.). That usually means either (a) not printing the secret at all and instructing users how to retrieve it securely, or (b) requiring an explicit, controlled sink such as a file path specified by the user, and writing the secret there with restricted permissions instead of logging it.

For this script, the least invasive change that preserves functionality is:

  • Keep generating and printing the non-sensitive values (public key and subject) as before.
  • Keep the --show-private flag, but change its behavior so that:
    • By default, the private key is not printed.
    • When --show-private is passed, the script requires the user to specify an output file path via an additional CLI argument (e.g., --out=/path/to/file), and writes the private key into that file instead of logging it. If no such argument is provided, the script warns the user and exits without printing the key.
  • Optionally, lock down the created file’s permissions (best effort in a cross‑platform way is hard; since we must not assume project-specific helpers, we can at least write to the file and leave permission-hardening as a user concern).

Concretely, in scripts/generate-vapid-keys.mjs:

  • Add an import for Node’s fs (and possibly path) standard module at the top.
  • Parse an optional --out=... or --out ... argument from process.argv.
  • Replace the two console.log calls that currently print VAPID_PRIVATE_KEY=${vapidKeys.privateKey} (both in the main section and in the docker-compose snippet) so they no longer interpolate or show the secret. Instead:
    • When showPrivate is true and an out path is provided, write the key to that file and print only a message like “Wrote VAPID_PRIVATE_KEY to ; store it securely.”
    • Update the docker-compose helper section to never embed the actual private key, only a placeholder and reminder to retrieve it from the file/secret store.
  • Keep all other behavior and messaging intact.
Suggested changeset 1
scripts/generate-vapid-keys.mjs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/generate-vapid-keys.mjs b/scripts/generate-vapid-keys.mjs
--- a/scripts/generate-vapid-keys.mjs
+++ b/scripts/generate-vapid-keys.mjs
@@ -8,10 +8,23 @@
  */
 
 import webPush from 'web-push';
+import fs from 'fs';
 
 const vapidKeys = webPush.generateVAPIDKeys();
 const showPrivate = process.argv.includes('--show-private');
 
+// Parse optional --out=<path> or --out <path> argument for writing the private key to a file.
+let outPath = null;
+const outFlagIndex = process.argv.findIndex(arg => arg === '--out' || arg.startsWith('--out='));
+if (outFlagIndex !== -1) {
+  const arg = process.argv[outFlagIndex];
+  if (arg === '--out' && process.argv[outFlagIndex + 1]) {
+    outPath = process.argv[outFlagIndex + 1];
+  } else if (arg.startsWith('--out=')) {
+    outPath = arg.slice('--out='.length);
+  }
+}
+
 console.log('VAPID Keys Generated');
 console.log('====================');
 console.log('');
@@ -22,14 +31,25 @@
 
 if (showPrivate) {
   console.log('');
-  console.log('# WARNING: The following VAPID_PRIVATE_KEY is sensitive.');
-  console.log('# It may be captured in shell history, CI logs, or terminal logs.');
-  console.log('# Prefer copying it directly into your secret store (e.g. .env, Key Vault)');
-  console.log('# and avoid committing or storing this output in version control.');
-  console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
+  console.log('# WARNING: VAPID_PRIVATE_KEY is sensitive and will not be printed to the console.');
+  console.log('# Instead, it can be written to a file that you then add to your secret store (e.g. .env, Key Vault).');
+  if (!outPath) {
+    console.log('# No --out path provided; re-run with:');
+    console.log('#   node scripts/generate-vapid-keys.mjs --show-private --out ./vapid-private-key.txt');
+    console.log('# to write the private key to the specified file.');
+  } else {
+    try {
+      fs.writeFileSync(outPath, `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`, { encoding: 'utf8' });
+      console.log(`# VAPID_PRIVATE_KEY has been written to: ${outPath}`);
+      console.log('# Protect this file and load the key from it into your secret store; avoid committing it to version control.');
+    } catch (err) {
+      console.error('# Failed to write VAPID_PRIVATE_KEY to file:', err.message);
+      console.error('# The private key has NOT been printed; fix the error and re-run the script.');
+    }
+  }
 } else {
   console.log('# VAPID_PRIVATE_KEY was generated but is hidden by default.');
-  console.log('# Re-run with "--show-private" to display it, or store it directly from code.');
+  console.log('# Re-run with "--show-private" and an "--out" path to write it to a file securely.');
 }
 
 console.log('');
@@ -41,9 +59,10 @@
 
 if (showPrivate) {
   console.log('    # WARNING: VAPID_PRIVATE_KEY is sensitive; avoid committing docker-compose.yml with this value.');
-  console.log('    # Store it in a secure secret manager or environment configuration.');
-  console.log(`    - VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
+  console.log('    # Retrieve the value from your secret store or from the file specified with "--out".');
+  console.log('    # Example (do NOT hard-code):');
+  console.log('    # - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_FROM_SECRET_STORE}');
 } else {
   console.log('    # VAPID_PRIVATE_KEY was generated but is hidden by default.');
-  console.log('    # Re-run with "--show-private" to display it.');
+  console.log('    # Re-run with "--show-private" and an "--out" path to write it to a file securely.');
 }
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
console.log('For docker-compose.yml:');
console.log('');
console.log(' environment:');
console.log(` - VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to vapidKeys
as clear text.

Copilot Autofix

AI 3 months ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

console.log(' - VAPID_SUBJECT=mailto:your-email@example.com');

if (showPrivate) {
console.log(` - VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to vapidKeys
as clear text.

Copilot Autofix

AI 3 months ago

In general, to fix clear-text logging of sensitive information, you should avoid sending the sensitive value directly to logging/console sinks. Instead, either (a) don’t output it at all, (b) output a redacted or hashed form, or (c) provide it through a safer channel (for example, write to a file specified by the user, which they can then move into a secret store, while avoiding accidental inclusion in generic logs).

For this script, the best fix that preserves functionality is to stop printing vapidKeys.privateKey directly in clear text. We can still (1) indicate that a private key was generated, (2) optionally write it to a file explicitly requested by the operator, and (3) keep the existing warnings. To avoid adding heavy dependencies, we can use Node’s built‑in fs module: if --show-private is passed, we will require a --out <path> argument to write the private key to that file instead of logging the value. The console output will clearly state where the key has been written, but will never include the key itself.

Concretely in scripts/generate-vapid-keys.mjs:

  • Add import fs from 'fs'; at the top (Node 18+ supports ESM default import for fs).
  • Parse an optional --out argument from process.argv.
  • In both places where vapidKeys.privateKey is logged (lines 29 and 45), replace the direct console.log of the key with:
    • If an outFile is provided, write the key to that file with fs.writeFileSync(outFile, vapidKeys.privateKey + '\n', { flag: 'w', mode: 0o600 }); and log a message like # VAPID_PRIVATE_KEY written to: <path>.
    • If no outFile is provided, log a message explaining that the key is generated but not printed, and instruct the user to re-run with --out pointing to a secure location.
  • Keep the existing warning comments about sensitivity, updating wording slightly to reflect the new behavior.

This avoids clear‑text logging of the sensitive key while keeping the script usable for secure key generation.

Suggested changeset 1
scripts/generate-vapid-keys.mjs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/generate-vapid-keys.mjs b/scripts/generate-vapid-keys.mjs
--- a/scripts/generate-vapid-keys.mjs
+++ b/scripts/generate-vapid-keys.mjs
@@ -8,9 +8,12 @@
  */
 
 import webPush from 'web-push';
+import fs from 'fs';
 
 const vapidKeys = webPush.generateVAPIDKeys();
 const showPrivate = process.argv.includes('--show-private');
+const outFileArgIndex = process.argv.indexOf('--out');
+const outFile = outFileArgIndex !== -1 ? process.argv[outFileArgIndex + 1] : undefined;
 
 console.log('VAPID Keys Generated');
 console.log('====================');
@@ -22,14 +22,21 @@
 
 if (showPrivate) {
   console.log('');
-  console.log('# WARNING: The following VAPID_PRIVATE_KEY is sensitive.');
-  console.log('# It may be captured in shell history, CI logs, or terminal logs.');
+  console.log('# WARNING: The VAPID_PRIVATE_KEY is sensitive.');
+  console.log('# It may be captured in shell history, CI logs, or terminal logs if printed directly.');
   console.log('# Prefer copying it directly into your secret store (e.g. .env, Key Vault)');
   console.log('# and avoid committing or storing this output in version control.');
-  console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
+  if (outFile) {
+    fs.writeFileSync(outFile, `${vapidKeys.privateKey}\n`, { flag: 'w', mode: 0o600 });
+    console.log(`# VAPID_PRIVATE_KEY has been written to: ${outFile}`);
+    console.log('# Ensure this file is stored securely and removed after importing into your secret store.');
+  } else {
+    console.log('# VAPID_PRIVATE_KEY was generated but is not printed to avoid leaking it in logs.');
+    console.log('# Re-run with "--show-private --out /secure/path/vapid_private_key" to write it to a file.');
+  }
 } else {
   console.log('# VAPID_PRIVATE_KEY was generated but is hidden by default.');
-  console.log('# Re-run with "--show-private" to display it, or store it directly from code.');
+  console.log('# Re-run with "--show-private --out /secure/path/vapid_private_key" to export it safely.');
 }
 
 console.log('');
@@ -42,8 +45,14 @@
 if (showPrivate) {
   console.log('    # WARNING: VAPID_PRIVATE_KEY is sensitive; avoid committing docker-compose.yml with this value.');
   console.log('    # Store it in a secure secret manager or environment configuration.');
-  console.log(`    - VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
+  if (outFile) {
+    console.log('    # The actual VAPID_PRIVATE_KEY value has been written to the file specified by "--out".');
+    console.log('    # Read it from that file when configuring your deployment, rather than pasting it here.');
+  } else {
+    console.log('    # VAPID_PRIVATE_KEY is not printed here to avoid leaking it in logs.');
+    console.log('    # Re-run with "--show-private --out /secure/path/vapid_private_key" to export it safely.');
+  }
 } else {
   console.log('    # VAPID_PRIVATE_KEY was generated but is hidden by default.');
-  console.log('    # Re-run with "--show-private" to display it.');
+  console.log('    # Re-run with "--show-private --out /secure/path/vapid_private_key" to display it safely.');
 }
EOF
@@ -8,9 +8,12 @@
*/

import webPush from 'web-push';
import fs from 'fs';

const vapidKeys = webPush.generateVAPIDKeys();
const showPrivate = process.argv.includes('--show-private');
const outFileArgIndex = process.argv.indexOf('--out');
const outFile = outFileArgIndex !== -1 ? process.argv[outFileArgIndex + 1] : undefined;

console.log('VAPID Keys Generated');
console.log('====================');
@@ -22,14 +22,21 @@

if (showPrivate) {
console.log('');
console.log('# WARNING: The following VAPID_PRIVATE_KEY is sensitive.');
console.log('# It may be captured in shell history, CI logs, or terminal logs.');
console.log('# WARNING: The VAPID_PRIVATE_KEY is sensitive.');
console.log('# It may be captured in shell history, CI logs, or terminal logs if printed directly.');
console.log('# Prefer copying it directly into your secret store (e.g. .env, Key Vault)');
console.log('# and avoid committing or storing this output in version control.');
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
if (outFile) {
fs.writeFileSync(outFile, `${vapidKeys.privateKey}\n`, { flag: 'w', mode: 0o600 });
console.log(`# VAPID_PRIVATE_KEY has been written to: ${outFile}`);
console.log('# Ensure this file is stored securely and removed after importing into your secret store.');
} else {
console.log('# VAPID_PRIVATE_KEY was generated but is not printed to avoid leaking it in logs.');
console.log('# Re-run with "--show-private --out /secure/path/vapid_private_key" to write it to a file.');
}
} else {
console.log('# VAPID_PRIVATE_KEY was generated but is hidden by default.');
console.log('# Re-run with "--show-private" to display it, or store it directly from code.');
console.log('# Re-run with "--show-private --out /secure/path/vapid_private_key" to export it safely.');
}

console.log('');
@@ -42,8 +45,14 @@
if (showPrivate) {
console.log(' # WARNING: VAPID_PRIVATE_KEY is sensitive; avoid committing docker-compose.yml with this value.');
console.log(' # Store it in a secure secret manager or environment configuration.');
console.log(` - VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
if (outFile) {
console.log(' # The actual VAPID_PRIVATE_KEY value has been written to the file specified by "--out".');
console.log(' # Read it from that file when configuring your deployment, rather than pasting it here.');
} else {
console.log(' # VAPID_PRIVATE_KEY is not printed here to avoid leaking it in logs.');
console.log(' # Re-run with "--show-private --out /secure/path/vapid_private_key" to export it safely.');
}
} else {
console.log(' # VAPID_PRIVATE_KEY was generated but is hidden by default.');
console.log(' # Re-run with "--show-private" to display it.');
console.log(' # Re-run with "--show-private --out /secure/path/vapid_private_key" to display it safely.');
}
Copilot is powered by AI and may make mistakes. Always verify output.
devartifex and others added 2 commits March 22, 2026 11:37
BREAKING: Complete infrastructure overhaul for cost/simplicity:

Removed (900+ lines deleted):
- VNet + 3 subnets + 3 NSGs + 3 private endpoints + 3 DNS zones
- App Insights + diagnostics module
- Storage account (subscription policy blocks shared key access)
- ACR Premium → Basic ($5/mo vs $50/mo)
- abbreviations.json + container-apps.json ARM template

Simplified:
- Key Vault: RBAC-only (no network restrictions, no PE)
- ACR: Basic SKU, deployer IP allowlist (no PE)
- Container Apps: no VNet, scale-to-zero (minReplicas: 0), EmptyDir volume
- Monitoring: Log Analytics only (no App Insights)
- Naming: readable cu-{env} pattern with 4-char hash for global uniqueness

Volume: EmptyDir (ephemeral) — subscription Azure Policy blocks
allowSharedKeyAccess on storage accounts, preventing SMB mounts.
Data at /data persists across container restarts but not scaling events.

Cost: ~$85-90/mo → ~$10-20/mo (75-85% savings)
Security: RBAC + managed identity (same protection, less complexity)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ensitive information

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@devartifex devartifex force-pushed the feature/session-persistence-security-pwa branch from 9a54d58 to 73d6f6c Compare March 22, 2026 10:50
- file-mentions.ts: Replace nested quantifier regex with flat character
  class to eliminate catastrophic backtracking (GHAS #9, high severity)
- ci.yml: Add explicit permissions (contents: read) to all workflow jobs
  following least-privilege principle (GHAS #5, #6, #7, medium severity)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@devartifex devartifex merged commit 75d1f65 into master Mar 22, 2026
6 of 8 checks passed
@devartifex devartifex deleted the feature/session-persistence-security-pwa branch March 22, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v4.0.0: Session persistence, PWA push, UI overhaul, Azure security

4 participants