feat(pwa): enhanced manifest + push notification support (#2674)

Integrated into release/v3.8.3
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-05-24 21:46:34 +02:00
committed by GitHub
parent 9971a5165f
commit 989994d2e5
6 changed files with 154 additions and 34 deletions

View File

@@ -35,7 +35,7 @@ export interface KeyGroupAuthResult {
export function authorizeKeyModelAccess(
apiKeyId: string | undefined,
model: string,
provider?: string,
provider?: string
): KeyGroupAuthResult {
if (!apiKeyId) {
// No API key = no restriction (public endpoint)

View File

@@ -89,3 +89,55 @@ self.addEventListener("fetch", (event) => {
})()
);
});
// ── Push Notifications ───────────────────────────────────────────────────────
self.addEventListener("push", (event) => {
let data;
try {
data = event.data ? event.data.json() : {};
} catch {
data = { title: "OmniRoute", body: event.data?.text() || "New notification" };
}
const title = data.title || "OmniRoute";
const options = {
body: data.body || "",
icon: data.icon || "/icon-512.png",
badge: data.badge || "/icon-192.png",
tag: data.tag || "omniroute-default",
data: {
url: data.url || "/dashboard",
timestamp: Date.now(),
},
vibrate: [200, 100, 200],
requireInteraction: true,
};
event.waitUntil(self.registration.showNotification(title, options));
});
// ── Notification Click ───────────────────────────────────────────────────────
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || "/dashboard";
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsList) => {
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && "focus" in client) {
client.focus();
if ("navigate" in client) {
client.navigate(urlToOpen);
}
return;
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
}),
);
});

View File

@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { addGroupPermission, removeGroupPermission, getGroupPermissions, getKeyGroup } from "@/lib/localDb";
import {
addGroupPermission,
removeGroupPermission,
getGroupPermissions,
getKeyGroup,
} from "@/lib/localDb";
type RouteParams = { params: Promise<{ id: string }> };
@@ -31,7 +36,10 @@ export async function POST(request: Request, { params }: RouteParams) {
const body = await request.json();
if (!body.modelPattern || !body.accessType) {
return NextResponse.json({ error: "modelPattern and accessType are required" }, { status: 400 });
return NextResponse.json(
{ error: "modelPattern and accessType are required" },
{ status: 400 }
);
}
if (body.accessType !== "allow" && body.accessType !== "deny") {
return NextResponse.json({ error: "accessType must be 'allow' or 'deny'" }, { status: 400 });

View File

@@ -1,9 +1,5 @@
import { NextResponse } from "next/server";
import {
getAllKeyGroups,
createKeyGroup,
getKeyGroup,
} from "@/lib/localDb";
import { getAllKeyGroups, createKeyGroup, getKeyGroup } from "@/lib/localDb";
/**
* GET /api/keys/groups — List all key groups

View File

@@ -2,28 +2,53 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "OmniRoute",
name: "OmniRoute AI Gateway",
short_name: "OmniRoute",
description:
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
start_url: "/",
start_url: "/dashboard",
scope: "/",
display: "fullscreen",
display: "standalone",
orientation: "any",
background_color: "#0b0f1a",
theme_color: "#0b0f1a",
categories: ["developer-tools", "productivity", "utilities"],
lang: "en",
dir: "ltr",
prefer_related_applications: false,
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
screenshots: [
{
src: "/screenshots/dashboard.png",
sizes: "1280x720",
type: "image/png",
form_factor: "wide",
label: "OmniRoute Dashboard",
},
],
};
}

View File

@@ -71,13 +71,16 @@ export function createKeyGroup(name: string, description = ""): KeyGroup {
const now = new Date().toISOString();
db.prepare(
"INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
).run(id, name, description, now, now);
return getKeyGroup(id)!;
}
export function updateKeyGroup(id: string, updates: { name?: string; description?: string; isActive?: boolean }): KeyGroup | undefined {
export function updateKeyGroup(
id: string,
updates: { name?: string; description?: string; isActive?: boolean }
): KeyGroup | undefined {
const existing = getKeyGroup(id);
if (!existing) return undefined;
@@ -85,9 +88,18 @@ export function updateKeyGroup(id: string, updates: { name?: string; description
const sets: string[] = [];
const params: Record<string, unknown> = { id };
if (updates.name !== undefined) { sets.push("name = @name"); params.name = updates.name; }
if (updates.description !== undefined) { sets.push("description = @description"); params.description = updates.description; }
if (updates.isActive !== undefined) { sets.push("is_active = @isActive"); params.isActive = updates.isActive ? 1 : 0; }
if (updates.name !== undefined) {
sets.push("name = @name");
params.name = updates.name;
}
if (updates.description !== undefined) {
sets.push("description = @description");
params.description = updates.description;
}
if (updates.isActive !== undefined) {
sets.push("is_active = @isActive");
params.isActive = updates.isActive ? 1 : 0;
}
if (sets.length === 0) return existing;
sets.push("updated_at = datetime('now')");
@@ -107,9 +119,11 @@ export function deleteKeyGroup(id: string): boolean {
export function getGroupPermissions(groupId: string): GroupModelPermission[] {
const db = getDbInstance() as any;
const rows = db.prepare(
"SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC",
).all(groupId) as any[];
const rows = db
.prepare(
"SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC"
)
.all(groupId) as any[];
return rows.map(rowToPermission);
}
@@ -117,14 +131,14 @@ export function addGroupPermission(
groupId: string,
modelPattern: string,
accessType: "allow" | "deny",
provider?: string,
provider?: string
): GroupModelPermission {
const db = getDbInstance() as any;
const id = randomUUID();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(id, groupId, modelPattern, provider || null, accessType, now);
return getGroupPermissions(groupId).find((p) => p.id === id)!;
@@ -145,27 +159,34 @@ export function clearGroupPermissions(groupId: string): void {
export function getGroupMembers(groupId: string): KeyGroupMember[] {
const db = getDbInstance() as any;
const rows = db.prepare(
"SELECT * FROM key_group_members WHERE group_id = ? ORDER BY created_at ASC",
).all(groupId) as any[];
const rows = db
.prepare("SELECT * FROM key_group_members WHERE group_id = ? ORDER BY created_at ASC")
.all(groupId) as any[];
return rows.map(rowToMember);
}
export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] {
const db = getDbInstance() as any;
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT g.* FROM key_groups g
INNER JOIN key_group_members m ON g.id = m.group_id
WHERE m.key_id = ? AND g.is_active = 1
ORDER BY g.name ASC
`).all(keyId) as any[];
`
)
.all(keyId) as any[];
return rows.map(rowToGroup);
}
export function addKeyToGroup(keyId: string, groupId: string): boolean {
const db = getDbInstance() as any;
try {
db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run(keyId, groupId);
db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run(
keyId,
groupId
);
return true;
} catch {
return false;
@@ -174,13 +195,17 @@ export function addKeyToGroup(keyId: string, groupId: string): boolean {
export function removeKeyFromGroup(keyId: string, groupId: string): boolean {
const db = getDbInstance() as any;
const result = db.prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?").run(keyId, groupId);
const result = db
.prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?")
.run(keyId, groupId);
return result.changes > 0;
}
function getGroupMemberCount(groupId: string): number {
const db = getDbInstance() as any;
const row = db.prepare("SELECT COUNT(*) as count FROM key_group_members WHERE group_id = ?").get(groupId) as any;
const row = db
.prepare("SELECT COUNT(*) as count FROM key_group_members WHERE group_id = ?")
.get(groupId) as any;
return row?.count || 0;
}
@@ -196,7 +221,11 @@ export interface ModelAccessCheck {
* Check if an API key has access to a specific model.
* Deny rules override allow rules. If no rules match, access is allowed by default.
*/
export function checkKeyModelAccess(keyId: string, model: string, provider?: string): ModelAccessCheck {
export function checkKeyModelAccess(
keyId: string,
model: string,
provider?: string
): ModelAccessCheck {
const groups = getKeyGroupsForApiKey(keyId);
if (groups.length === 0) {
// No groups = no restrictions
@@ -207,17 +236,24 @@ export function checkKeyModelAccess(keyId: string, model: string, provider?: str
const groupIds = groups.map((g) => g.id);
const placeholders = groupIds.map(() => "?").join(",");
const rules = db.prepare(`
const rules = db
.prepare(
`
SELECT * FROM group_model_permissions
WHERE group_id IN (${placeholders})
ORDER BY access_type ASC
`).all(...groupIds) as any[];
`
)
.all(...groupIds) as any[];
const permissions = rules.map(rowToPermission);
// Check deny rules first (they take precedence)
const denyRules = permissions.filter(
(p) => p.accessType === "deny" && matchesModelPattern(p.modelPattern, model) && (!p.provider || p.provider === provider),
(p) =>
p.accessType === "deny" &&
matchesModelPattern(p.modelPattern, model) &&
(!p.provider || p.provider === provider)
);
if (denyRules.length > 0) {
@@ -226,7 +262,10 @@ export function checkKeyModelAccess(keyId: string, model: string, provider?: str
// Check allow rules
const allowRules = permissions.filter(
(p) => p.accessType === "allow" && matchesModelPattern(p.modelPattern, model) && (!p.provider || p.provider === provider),
(p) =>
p.accessType === "allow" &&
matchesModelPattern(p.modelPattern, model) &&
(!p.provider || p.provider === provider)
);
if (allowRules.length > 0) {