Skip to content

Commit 815be0e

Browse files
committed
Quiet Push Service (QPS)
QPS provides the ability to deliver push notifications to devices using Quiet, using the Firebase Cloud Messaging API to support both Android and iOS service. This adds an HTTP API with two endpoints: - `POST /v1/register` will register a device notification token and the server will respond back with a UCAN token to indicate. - `POST /v1/push` will send a single notification to a single device, based on the provided UCAN token. Includes a tool to test WebPush notification via Firebase. TODO: - Still finalize data structures for notification payload.
1 parent 0d6dd9d commit 815be0e

25 files changed

+9604
-11547
lines changed

app/.env.dev

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ MIKRO_ORM_DEBUG=true
1818
MIKRO_ORM_PREFER_TS=false
1919
HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
2020
HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000
21+
22+
# QPS (Quiet Push Service) settings
23+
QPS_ENABLED=true
24+
# Firebase Cloud Messaging settings (required when QPS_ENABLED=true)
25+
# FIREBASE_PROJECT_ID=your-project-id
26+
# FIREBASE_CLIENT_EMAIL=your-client-email@your-project-id.iam.gserviceaccount.com
27+
# FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

app/.env.local

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ MIKRO_ORM_DEBUG=false
2020
MIKRO_ORM_PREFER_TS=false
2121
HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
2222
HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000
23+
24+
# QPS (Quiet Push Service) settings
25+
QPS_ENABLED=true
26+
# Firebase Cloud Messaging settings (required when QPS_ENABLED=true)
27+
# FIREBASE_PROJECT_ID=your-project-id
28+
# FIREBASE_CLIENT_EMAIL=your-client-email@your-project-id.iam.gserviceaccount.com
29+
# FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

app/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@
5656
"prepare": "husky"
5757
},
5858
"dependencies": {
59-
"@aws-sdk/client-lambda": "^3.865.0",
60-
"@aws-sdk/client-secrets-manager": "^3.864.0",
61-
"@aws-sdk/rds-signer": "^3.864.0",
59+
"@aws-sdk/client-lambda": "^3.981.0",
60+
"@aws-sdk/client-secrets-manager": "^3.981.0",
61+
"@aws-sdk/rds-signer": "^3.981.0",
6262
"@dotenvx/dotenvx": "^1.36.0",
6363
"@localfirst/auth": "workspace:*",
6464
"@localfirst/crdx": "workspace:*",
@@ -103,7 +103,9 @@
103103
"uint8arrays": "^5.1.0",
104104
"winston": "^3.17.0",
105105
"winston-aws-cloudwatch": "^3.0.0",
106-
"winston-daily-rotate-file": "^5.0.0"
106+
"winston-daily-rotate-file": "^5.0.0",
107+
"@ucans/ucans": "^0.12.0",
108+
"firebase-admin": "^13.6.0"
107109
},
108110
"devDependencies": {
109111
"@babel/preset-typescript": "^7.27.0",

app/src/client/cli/prompts/main.prompt.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { WebsocketClient } from '../../ws.client.js'
55
import { createLogger } from '../../../nest/app/logger/logger.js'
66
import { confirm } from '@inquirer/prompts'
77
import { createCommunity, getCommunity } from './community.prompt.js'
8+
import { registerDevice, sendPushNotification } from './push.prompt.js'
89
import type { Community } from '../../../nest/communities/types.js'
910

1011
const logger = createLogger('Client:Main')
@@ -29,6 +30,8 @@ const mainLoop = async (
2930
options: RuntimeOptions,
3031
): Promise<boolean> => {
3132
let exit = false
33+
let lastUcan: string | undefined = undefined
34+
3235
while (!exit) {
3336
const defaultChoices = [
3437
{
@@ -41,6 +44,16 @@ const mainLoop = async (
4144
value: 'getCommunity',
4245
description: 'Get a community by ID from the server',
4346
},
47+
{
48+
name: 'Register device (Push)',
49+
value: 'registerDevice',
50+
description: 'Register a device for push notifications and get a UCAN',
51+
},
52+
{
53+
name: 'Send push notification',
54+
value: 'sendPush',
55+
description: 'Send a push notification using a UCAN token',
56+
},
4457
{
4558
name: 'Disconnect',
4659
value: 'disconnect',
@@ -69,6 +82,17 @@ const mainLoop = async (
6982
logger.log(JSON.stringify(community, null, 2))
7083
break
7184
}
85+
case 'registerDevice': {
86+
const ucan = await registerDevice(options)
87+
if (ucan != null) {
88+
lastUcan = ucan
89+
}
90+
break
91+
}
92+
case 'sendPush': {
93+
await sendPushNotification(options, lastUcan)
94+
break
95+
}
7296
case 'disconnect': {
7397
client.close()
7498
const shouldConnectClient = await confirm({
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { input } from '@inquirer/prompts'
2+
import { createLogger } from '../../../nest/app/logger/logger.js'
3+
import { promiseWithSpinner } from '../utils/utils.js'
4+
import type { RuntimeOptions } from '../types.js'
5+
6+
const logger = createLogger('Client:Push')
7+
8+
/**
9+
* Response from the /v1/register endpoint
10+
*/
11+
interface RegisterResponse {
12+
ucan: string
13+
}
14+
15+
/**
16+
* Error response from push endpoints
17+
*/
18+
interface PushErrorResponse {
19+
error: string
20+
code?: string
21+
}
22+
23+
/**
24+
* Type guard for RegisterResponse
25+
*/
26+
const isRegisterResponse = (data: unknown): data is RegisterResponse =>
27+
typeof data === 'object' &&
28+
data !== null &&
29+
'ucan' in data &&
30+
typeof (data as { ucan: unknown }).ucan === 'string'
31+
32+
/**
33+
* Type guard for PushErrorResponse
34+
*/
35+
const isPushErrorResponse = (data: unknown): data is PushErrorResponse =>
36+
typeof data === 'object' &&
37+
data !== null &&
38+
'error' in data &&
39+
typeof (data as { error: unknown }).error === 'string'
40+
41+
/**
42+
* Build the base URL for QPS endpoints
43+
*/
44+
const getBaseUrl = (options: RuntimeOptions): string => {
45+
const hostname = options.hostname ?? 'localhost'
46+
const port = options.port ?? '3000'
47+
return `http://${hostname}:${port}`
48+
}
49+
50+
/**
51+
* Register a device and get a UCAN token
52+
*/
53+
export const registerDevice = async (
54+
options: RuntimeOptions,
55+
): Promise<string | undefined> => {
56+
const deviceToken = await input({
57+
message: 'Enter the FCM device token:',
58+
validate: (value: string | undefined) => {
59+
if (value == null || value === '') {
60+
return 'Device token is required'
61+
}
62+
return true
63+
},
64+
})
65+
66+
const bundleId = await input({
67+
message: 'Enter the app bundle ID:',
68+
default: 'com.tryquiet.quiet',
69+
validate: (value: string | undefined) => {
70+
if (value == null || value === '') {
71+
return 'Bundle ID is required'
72+
}
73+
return true
74+
},
75+
})
76+
77+
const baseUrl = getBaseUrl(options)
78+
const url = `${baseUrl}/v1/register`
79+
80+
const result = await promiseWithSpinner(
81+
async () => {
82+
const response = await fetch(url, {
83+
method: 'POST',
84+
headers: {
85+
'Content-Type': 'application/json',
86+
},
87+
body: JSON.stringify({
88+
deviceToken,
89+
bundleId,
90+
}),
91+
})
92+
93+
if (!response.ok) {
94+
const errorBody: unknown = await response.json()
95+
const errorMessage = isPushErrorResponse(errorBody)
96+
? errorBody.error
97+
: response.statusText
98+
throw new Error(`Registration failed: ${errorMessage}`)
99+
}
100+
101+
const data: unknown = await response.json()
102+
if (!isRegisterResponse(data)) {
103+
throw new Error('Invalid response format from server')
104+
}
105+
return data.ucan
106+
},
107+
'Registering device...',
108+
'Device registered successfully!',
109+
'Failed to register device',
110+
)
111+
112+
if (result != null) {
113+
logger.log(`UCAN Token:\n${result}`)
114+
return result
115+
}
116+
117+
return undefined
118+
}
119+
120+
/**
121+
* Send a push notification using a UCAN token
122+
*/
123+
export const sendPushNotification = async (
124+
options: RuntimeOptions,
125+
existingUcan?: string,
126+
): Promise<boolean> => {
127+
const ucanInput = await input({
128+
message: 'Enter the UCAN token:',
129+
default: existingUcan,
130+
validate: (value: string | undefined) => {
131+
if (value == null || value.trim() === '') {
132+
return 'UCAN token is required'
133+
}
134+
return true
135+
},
136+
})
137+
// Trim whitespace and remove any newlines that might have been introduced
138+
const ucan = ucanInput.trim().replace(/\s+/g, '')
139+
140+
const title = await input({
141+
message: 'Enter notification title (optional, press Enter to skip):',
142+
default: undefined,
143+
})
144+
145+
const body = await input({
146+
message: 'Enter notification body (optional, press Enter to skip):',
147+
default: undefined,
148+
})
149+
150+
const dataInput = await input({
151+
message:
152+
'Enter custom data as JSON (optional, press Enter to skip, e.g., {"key":"value"}):',
153+
default: undefined,
154+
validate: (value: string | undefined) => {
155+
if (value == null || value === '') {
156+
return true
157+
}
158+
try {
159+
JSON.parse(value)
160+
return true
161+
} catch {
162+
return 'Invalid JSON format'
163+
}
164+
},
165+
})
166+
167+
const baseUrl = getBaseUrl(options)
168+
const url = `${baseUrl}/v1/push`
169+
170+
// Build request body
171+
const requestBody: {
172+
ucan: string
173+
title?: string
174+
body?: string
175+
data?: Record<string, string>
176+
} = { ucan }
177+
178+
if (title !== '') {
179+
requestBody.title = title
180+
}
181+
if (body !== '') {
182+
requestBody.body = body
183+
}
184+
if (dataInput !== '') {
185+
const parsed: unknown = JSON.parse(dataInput)
186+
requestBody.data = parsed as Record<string, string>
187+
}
188+
189+
const result = await promiseWithSpinner(
190+
async () => {
191+
const response = await fetch(url, {
192+
method: 'POST',
193+
headers: {
194+
'Content-Type': 'application/json',
195+
},
196+
body: JSON.stringify(requestBody),
197+
})
198+
199+
if (!response.ok) {
200+
let errorMessage = response.statusText
201+
try {
202+
const errorBody: unknown = await response.json()
203+
if (isPushErrorResponse(errorBody)) {
204+
;({ error: errorMessage } = errorBody)
205+
}
206+
} catch {
207+
// Response might not be JSON
208+
}
209+
210+
if (response.status === 400) {
211+
throw new Error(`Invalid UCAN token: ${errorMessage}`)
212+
} else if (response.status === 410) {
213+
throw new Error(`Device token is no longer valid: ${errorMessage}`)
214+
} else {
215+
throw new Error(`Push notification failed: ${errorMessage}`)
216+
}
217+
}
218+
219+
return true
220+
},
221+
'Sending push notification...',
222+
'Push notification sent successfully!',
223+
'Failed to send push notification',
224+
)
225+
226+
return result === true
227+
}

app/src/nest/app/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CommunitiesModule } from '../communities/communities.module.js'
99
import { HealthModule } from '../rest/health/health.module.js'
1010
import { AWSModule } from '../utils/aws/aws.module.js'
1111
import { UtilsModule } from '../utils/utils.module.js'
12+
import { QPSModule } from '../qps/qps.module.js'
1213

1314
@Module({
1415
imports: [
@@ -21,6 +22,7 @@ import { UtilsModule } from '../utils/utils.module.js'
2122
StorageModule,
2223
HealthModule,
2324
AWSModule,
25+
QPSModule.register(),
2426
],
2527
controllers: [],
2628
providers: [],

app/src/nest/communities/sync/log-entry-sync.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export class LogEntrySyncManager implements OnModuleDestroy {
106106

107107
const maxBytes = 1000 * 1000 * 0.8 // maximum 1MB with 20% buffer
108108
const entries: LogEntrySyncEntity[] = []
109-
let cursor = payload.cursor
109+
let { cursor } = payload
110110
let hasNextPage = false
111111
let usedBytes = 0
112112
let nextCursor = payload.cursor

0 commit comments

Comments
 (0)