Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,20 @@ DREAMSYNC_MAPPING_DB_PATH="/path/to/dreamsync/mapping/db"
GROUP_CHARTER_MAPPING_DB_PATH=/path/to/charter/mapping/db
CERBERUS_MAPPING_DB_PATH=/path/to/cerberus/mapping/db

GOOGLE_APPLICATION_CREDENTIALS="/path/to/firebase-secrets.json"
GOOGLE_APPLICATION_CREDENTIALS="/Users/sosweetham/projs/metastate/prototype/secrets/eid-w-firebase-adminsdk.json"

# Notification Trigger (APNS/FCM toy platform)
NOTIFICATION_TRIGGER_PORT=3998
# Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT)
NOTIFICATION_TRIGGER_URL=http://localhost:3998
# APNS (iOS) - from Apple Developer
APNS_KEY_PATH="/Users/sosweetham/projs/metastate/prototype/secrets/AuthKey_A3BBXD9YR3.p8"
APNS_KEY_ID="A3BBXD9YR3"
APNS_TEAM_ID="M49C8XS835"
APNS_BUNDLE_ID="com.example.app"
APNS_PRODUCTION=false
# Broadcast push (Live Activities) - base64 channel ID
APNS_BROADCAST_CHANNEL_ID=znbhuBJCEfEAAMIJbS9xUw==
Comment on lines +40 to +53
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replace machine-specific values in .env.example with placeholders.

Line 40 and Line 47 currently expose local absolute paths (including a username), and the APNS block uses concrete account identifiers. This is a portability and privacy/compliance risk for a template file.

🔧 Proposed fix
-GOOGLE_APPLICATION_CREDENTIALS="/Users/sosweetham/projs/metastate/prototype/secrets/eid-w-firebase-adminsdk.json"
+GOOGLE_APPLICATION_CREDENTIALS=/path/to/firebase-service-account.json

 # Notification Trigger (APNS/FCM toy platform)
 NOTIFICATION_TRIGGER_PORT=3998
 # Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT)
 NOTIFICATION_TRIGGER_URL=http://localhost:3998
 # APNS (iOS) - from Apple Developer
-APNS_KEY_PATH="/Users/sosweetham/projs/metastate/prototype/secrets/AuthKey_A3BBXD9YR3.p8"
-APNS_KEY_ID="A3BBXD9YR3"
-APNS_TEAM_ID="M49C8XS835"
-APNS_BUNDLE_ID="com.example.app"
+APNS_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
+APNS_KEY_ID=YOUR_APNS_KEY_ID
+APNS_TEAM_ID=YOUR_APNS_TEAM_ID
+APNS_BUNDLE_ID=com.example.app
 APNS_PRODUCTION=false
 # Broadcast push (Live Activities) - base64 channel ID
-APNS_BROADCAST_CHANNEL_ID=znbhuBJCEfEAAMIJbS9xUw==
+APNS_BROADCAST_CHANNEL_ID=YOUR_BASE64_CHANNEL_ID
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
GOOGLE_APPLICATION_CREDENTIALS="/Users/sosweetham/projs/metastate/prototype/secrets/eid-w-firebase-adminsdk.json"
# Notification Trigger (APNS/FCM toy platform)
NOTIFICATION_TRIGGER_PORT=3998
# Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT)
NOTIFICATION_TRIGGER_URL=http://localhost:3998
# APNS (iOS) - from Apple Developer
APNS_KEY_PATH="/Users/sosweetham/projs/metastate/prototype/secrets/AuthKey_A3BBXD9YR3.p8"
APNS_KEY_ID="A3BBXD9YR3"
APNS_TEAM_ID="M49C8XS835"
APNS_BUNDLE_ID="com.example.app"
APNS_PRODUCTION=false
# Broadcast push (Live Activities) - base64 channel ID
APNS_BROADCAST_CHANNEL_ID=znbhuBJCEfEAAMIJbS9xUw==
GOOGLE_APPLICATION_CREDENTIALS=/path/to/firebase-service-account.json
# Notification Trigger (APNS/FCM toy platform)
NOTIFICATION_TRIGGER_PORT=3998
# Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT)
NOTIFICATION_TRIGGER_URL=http://localhost:3998
# APNS (iOS) - from Apple Developer
APNS_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
APNS_KEY_ID=YOUR_APNS_KEY_ID
APNS_TEAM_ID=YOUR_APNS_TEAM_ID
APNS_BUNDLE_ID=com.example.app
APNS_PRODUCTION=false
# Broadcast push (Live Activities) - base64 channel ID
APNS_BROADCAST_CHANNEL_ID=YOUR_BASE64_CHANNEL_ID
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 40-40: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 47-47: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 47-47: [UnorderedKey] The APNS_KEY_PATH key should go before the NOTIFICATION_TRIGGER_PORT key

(UnorderedKey)


[warning] 48-48: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 48-48: [UnorderedKey] The APNS_KEY_ID key should go before the APNS_KEY_PATH key

(UnorderedKey)


[warning] 49-49: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 49-49: [UnorderedKey] The APNS_TEAM_ID key should go before the NOTIFICATION_TRIGGER_PORT key

(UnorderedKey)


[warning] 50-50: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 50-50: [UnorderedKey] The APNS_BUNDLE_ID key should go before the APNS_KEY_ID key

(UnorderedKey)


[warning] 51-51: [UnorderedKey] The APNS_PRODUCTION key should go before the APNS_TEAM_ID key

(UnorderedKey)


[warning] 53-53: [UnorderedKey] The APNS_BROADCAST_CHANNEL_ID key should go before the APNS_BUNDLE_ID key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 40 - 53, Replace machine-specific absolute paths
and account identifiers in the template with neutral placeholders: change
GOOGLE_APPLICATION_CREDENTIALS and APNS_KEY_PATH from local user paths to
generic placeholders like /path/to/credentials.json or
./secrets/credentials.json, and replace APNS_KEY_ID, APNS_TEAM_ID,
APNS_BUNDLE_ID and APNS_BROADCAST_CHANNEL_ID with clearly marked placeholder
tokens (e.g. <APNS_KEY_ID>, <APNS_TEAM_ID>, <APNS_BUNDLE_ID>,
<APNS_BROADCAST_CHANNEL_ID>); also ensure NOTIFICATION_TRIGGER_URL remains a
generic default (e.g. http://localhost:${NOTIFICATION_TRIGGER_PORT}) rather than
embedding any machine-specific values so the .env.example is portable and
non-sensitive.


#PUBLIC_REGISTRY_URL="https://registry.w3ds.metastate.foundation"
#PUBLIC_PROVISIONER_URL="https://provisioner.w3ds.metastate.foundation"
Expand Down
3 changes: 3 additions & 0 deletions infrastructure/control-panel/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ LOKI_PASSWORD=admin

# Registry Configuration
PUBLIC_REGISTRY_URL=https://registry.staging.metastate.foundation

# Notification Trigger (for Notifications tab proxy)
NOTIFICATION_TRIGGER_URL=http://localhost:3998
132 changes: 132 additions & 0 deletions infrastructure/control-panel/src/lib/services/notificationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { env } from '$env/dynamic/private';

export interface NotificationPayload {
title: string;
body: string;
subtitle?: string;
data?: Record<string, string>;
sound?: string;
badge?: number;
clickAction?: string;
}

export interface SendNotificationRequest {
token: string;
platform?: 'ios' | 'android';
payload: NotificationPayload;
}

export interface SendResult {
success: boolean;
error?: string;
}

function getBaseUrl(): string {
const url = env.NOTIFICATION_TRIGGER_URL;
if (url) return url;
const port = env.NOTIFICATION_TRIGGER_PORT || '3998';
return `http://localhost:${port}`;
}

export async function sendNotification(
request: SendNotificationRequest
): Promise<SendResult> {
const baseUrl = getBaseUrl();
try {
const response = await fetch(`${baseUrl}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: AbortSignal.timeout(15000)
});
const data = await response.json();
if (data.success) return { success: true };
return { success: false, error: data.error ?? 'Unknown error' };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Request failed'
};
}
}

export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
Comment on lines +56 to +58
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

PUBLIC_PROVISIONER_URL is never accessible from $env/dynamic/private, and the dynamic re-import is redundant.

$env/dynamic/private only includes variables that do not begin with config.kit.env.publicPrefix (which defaults to PUBLIC_). Accessing env.PUBLIC_PROVISIONER_URL from this module will always return undefined, silently falling through to PROVISIONER_URL. Additionally, env was already imported at the module level on Line 1, making the await import(...) on Line 56 a redundant re-import that just shadows the outer binding.

Remove the dynamic re-import and read PROVISIONER_URL directly from the module-level env; if PUBLIC_PROVISIONER_URL must be supported, import it separately from $env/dynamic/public.

🐛 Proposed fix
 export async function getDevicesWithTokens(): Promise<
     { token: string; platform: string; eName: string }[]
 > {
-    const { env } = await import('$env/dynamic/private');
-    const provisionerUrl =
-        env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
+    const provisionerUrl = env.PROVISIONER_URL || 'http://localhost:3001';
     try {

Apply the same fix to getDevicesByEName() (Lines 75-77).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
const provisionerUrl = env.PROVISIONER_URL || 'http://localhost:3001';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infrastructure/control-panel/src/lib/services/notificationService.ts` around
lines 56 - 58, Remove the redundant dynamic re-import of env and stop reading
PUBLIC_PROVISIONER_URL from the private env binding: use the module-level env
already imported at the top to read PROVISIONER_URL when computing
provisionerUrl (i.e., replace the await import('$env/dynamic/private') usage and
the env.PUBLIC_PROVISIONER_URL fallback with env.PROVISIONER_URL ||
'http://localhost:3001'); if you need to support PUBLIC_PROVISIONER_URL, import
it explicitly from '$env/dynamic/public' and prefer that value. Apply the same
change to the getDevicesByEName() code path where env is re-imported and
PUBLIC_PROVISIONER_URL is referenced.

try {
const response = await fetch(`${provisionerUrl}/api/devices/list`, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return data.devices ?? [];
} catch (err) {
console.error('Failed to fetch devices:', err);
return [];
}
}

export async function getDevicesByEName(eName: string): Promise<
{ token: string; platform: string; eName: string }[]
> {
const { env } = await import('$env/dynamic/private');
const provisionerUrl =
env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
try {
const response = await fetch(
`${provisionerUrl}/api/devices/by-ename/${encodeURIComponent(eName)}`,
{ signal: AbortSignal.timeout(10000) }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return data.devices ?? [];
} catch (err) {
console.error('Failed to fetch devices by eName:', err);
return [];
}
}

export async function sendBulkNotifications(
tokens: string[],
payload: NotificationPayload,
platform?: 'ios' | 'android'
): Promise<{ sent: number; failed: number; errors: { token: string; error: string }[] }> {
const results = await Promise.all(
tokens.map(async (token) => {
const result = await sendNotification({
token: token.trim(),
platform,
payload
});
return { token: token.trim(), ...result };
})
);

const sent = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success);
return {
sent,
failed: failed.length,
errors: failed.map((r) => ({ token: r.token.slice(0, 20) + '...', error: r.error ?? 'Unknown' }))
};
}
Comment on lines +92 to +115
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Promise.all over an unbounded token list with no concurrency cap.

All sendNotification requests are fired simultaneously. For large token arrays this can saturate the notification trigger's connection pool or hit OS socket limits. Consider limiting concurrency (e.g., process in chunks of 50–100).

♻️ Proposed fix using a simple chunk helper
+async function* chunks<T>(arr: T[], size: number) {
+    for (let i = 0; i < arr.length; i += size) yield arr.slice(i, i + size);
+}

 export async function sendBulkNotifications(
     tokens: string[],
     payload: NotificationPayload,
     platform?: 'ios' | 'android'
 ): Promise<{ sent: number; failed: number; errors: { token: string; error: string }[] }> {
-    const results = await Promise.all(
-        tokens.map(async (token) => {
-            const result = await sendNotification({ token: token.trim(), platform, payload });
-            return { token: token.trim(), ...result };
-        })
-    );
+    const results: ({ token: string } & SendResult)[] = [];
+    for await (const batch of chunks(tokens, 50)) {
+        const batchResults = await Promise.all(
+            batch.map(async (token) => {
+                const result = await sendNotification({ token: token.trim(), platform, payload });
+                return { token: token.trim(), ...result };
+            })
+        );
+        results.push(...batchResults);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infrastructure/control-panel/src/lib/services/notificationService.ts` around
lines 92 - 115, The sendBulkNotifications function currently fires
sendNotification for every token via Promise.all, which can overwhelm resources;
modify sendBulkNotifications to process tokens in bounded concurrent batches
(e.g., chunks of 50 or 100) instead of all at once by creating a small chunk
helper or loop that slices tokens into batches, awaits Promise.all for each
batch of sendNotification calls, collects and aggregates results exactly as
before (use token.trim(), preserve returned fields success/error), and return
the same { sent, failed, errors } shape; reference sendBulkNotifications and
sendNotification when locating the change.


export async function checkNotificationTriggerHealth(): Promise<{
ok: boolean;
apns: boolean;
fcm: boolean;
}> {
const baseUrl = getBaseUrl();
try {
const response = await fetch(`${baseUrl}/api/health`, {
signal: AbortSignal.timeout(5000)
});
const data = await response.json();
return { ok: data.ok ?? false, apns: data.apns ?? false, fcm: data.fcm ?? false };
} catch {
return { ok: false, apns: false, fcm: false };
}
}
3 changes: 2 additions & 1 deletion infrastructure/control-panel/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
const navLinks = [
{ label: 'Dashboard', href: '/' },
{ label: 'Monitoring', href: '/monitoring' },
{ label: 'Actions', href: '/actions' }
{ label: 'Actions', href: '/actions' },
{ label: 'Notifications', href: '/notifications' }
];

const isActive = (href: string) => (href === '/' ? pageUrl === '/' : pageUrl.startsWith(href));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getDevicesWithTokens } from '$lib/services/notificationService';

export const GET: RequestHandler = async () => {
try {
const devices = await getDevicesWithTokens();
return json({ count: devices.length });
} catch {
return json({ count: 0 });
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { checkNotificationTriggerHealth } from '$lib/services/notificationService';

export const GET: RequestHandler = async () => {
try {
const health = await checkNotificationTriggerHealth();
return json(health);
} catch {
return json({ ok: false, apns: false, fcm: false });
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import {
getDevicesWithTokens,
sendBulkNotifications
} from '$lib/services/notificationService';

export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const { payload } = body;

if (!payload?.title || !payload?.body) {
return json(
{ success: false, error: 'Missing payload.title or payload.body' },
{ status: 400 }
);
}

const devices = await getDevicesWithTokens();
const tokens = devices.map((d) => d.token);

if (tokens.length === 0) {
return json(
{
success: false,
error: 'No registered devices with push tokens found'
},
{ status: 400 }
);
}

const result = await sendBulkNotifications(
tokens,
{
title: String(payload.title),
body: String(payload.body),
subtitle: payload.subtitle ? String(payload.subtitle) : undefined,
data: payload.data,
sound: payload.sound ? String(payload.sound) : undefined,
badge: payload.badge !== undefined ? Number(payload.badge) : undefined,
clickAction: payload.clickAction ? String(payload.clickAction) : undefined
}
// platform auto-detected per token
);

return json({
success: true,
sent: result.sent,
failed: result.failed,
total: tokens.length,
errors: result.errors
});
} catch (err) {
console.error('Bulk-all send error:', err);
return json(
{ success: false, error: err instanceof Error ? err.message : 'Internal error' },
{ status: 500 }
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { sendBulkNotifications } from '$lib/services/notificationService';

export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const { tokens, platform, payload } = body;

if (!Array.isArray(tokens) || tokens.length === 0) {
return json({ success: false, error: 'tokens must be a non-empty array' }, { status: 400 });
}
if (!payload?.title || !payload?.body) {
return json(
{ success: false, error: 'Missing payload.title or payload.body' },
{ status: 400 }
);
}

const validTokens = tokens
.filter((t: unknown) => typeof t === 'string' && t.trim().length > 0)
.map((t: string) => t.trim());

if (validTokens.length === 0) {
return json({ success: false, error: 'No valid tokens' }, { status: 400 });
}

const result = await sendBulkNotifications(
validTokens,
{
title: String(payload.title),
body: String(payload.body),
subtitle: payload.subtitle ? String(payload.subtitle) : undefined,
data: payload.data,
sound: payload.sound ? String(payload.sound) : undefined,
badge: payload.badge !== undefined ? Number(payload.badge) : undefined,
clickAction: payload.clickAction ? String(payload.clickAction) : undefined
},
platform && ['ios', 'android'].includes(platform) ? platform : undefined
);

return json({
success: true,
sent: result.sent,
failed: result.failed,
errors: result.errors
});
Comment on lines +42 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing total field in success response — inconsistent with other bulk endpoints.

send-bulk-all and send-by-ename both return total: tokens.length. send-bulk omits it, making responses inconsistent for any consumer that inspects all three.

🔧 Proposed fix
 		return json({
 			success: true,
+			total: validTokens.length,
 			sent: result.sent,
 			failed: result.failed,
 			errors: result.errors
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@infrastructure/control-panel/src/routes/api/notifications/send-bulk/`+server.ts
around lines 42 - 47, The success JSON response is missing a total field,
causing inconsistency with other bulk endpoints; update the response returned by
the handler that builds the json({ success, sent: result.sent, failed:
result.failed, errors: result.errors }) to include total — either use total:
result.sent + result.failed or, if the tokens array is in scope, total:
tokens.length — so the returned object contains total, sent, failed, and errors;
change the return to json({... , total: <choose one of the two expressions
above> }).

} catch (err) {
console.error('Bulk notification send error:', err);
return json(
{ success: false, error: err instanceof Error ? err.message : 'Internal error' },
{ status: 500 }
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import {
getDevicesByEName,
sendBulkNotifications
} from '$lib/services/notificationService';

export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const { eName, payload } = body;

if (!eName || typeof eName !== 'string' || !eName.trim()) {
return json(
{ success: false, error: 'Missing or invalid eName' },
{ status: 400 }
);
}
if (!payload?.title || !payload?.body) {
return json(
{ success: false, error: 'Missing payload.title or payload.body' },
{ status: 400 }
);
}

const devices = await getDevicesByEName(eName.trim());
const tokens = devices.map((d) => d.token);

if (tokens.length === 0) {
return json(
{
success: false,
error: `No devices with push tokens found for eName: ${eName}`
},
{ status: 400 }
);
}

const result = await sendBulkNotifications(tokens, {
title: String(payload.title),
body: String(payload.body),
subtitle: payload.subtitle ? String(payload.subtitle) : undefined,
data: payload.data,
sound: payload.sound ? String(payload.sound) : undefined,
badge: payload.badge !== undefined ? Number(payload.badge) : undefined,
clickAction: payload.clickAction ? String(payload.clickAction) : undefined
});

return json({
success: true,
sent: result.sent,
failed: result.failed,
total: tokens.length,
errors: result.errors
});
} catch (err) {
console.error('Send by eName error:', err);
return json(
{ success: false, error: err instanceof Error ? err.message : 'Internal error' },
{ status: 500 }
);
}
};
Loading
Loading