46 Commits
Author SHA1 Message Date
Jose Olarte III 6e4e1e7f1b chore: rename FCM_TOKEN_DATA_DIR env var to NOTIFY_DATA_DIR
Align the data-directory env name with notify.sqlite; default path remains ./data.
2026-08-04 17:31:14 +08:00
trentlarson a45150e25f bump version to 0.1.3 and add to CHANGELOG 2026-07-30 20:21:03 -06:00
jose d8989f587e Merge pull request 'Convert JSON storage to SQLite' (#3) from sqlite-storage into master
Reviewed-on: #3
2026-07-29 11:40:59 +00:00
Jose Olarte III 9e6597638e chore(db): rename SQLite database file to notify.sqlite
Use a generic database filename for future notification-related storage, and update docs and env comments to match.
2026-07-27 16:53:53 +08:00
Jose Olarte III 06ec0f63e4 docs: document SQLite storage for FCM registrations
Update README and .env.example for the SQLite database location, schema overview, backup/WAL notes, and clarify that JSON data is not migrated.
2026-07-24 20:52:38 +08:00
Jose Olarte III ddb969c26d chore(db): remove obsolete JSON FCM token repository
Delete the unused JSON storage implementation now that SQLite is the sole persistence backend.
2026-07-24 17:57:44 +08:00
Jose Olarte III e79d55fac6 perf(db): tighten SQLite FCM registration queries
Drop the upsert pre-read, project only needed columns, and add a (user_id, fcm_token) index for token lookups and dedupe deletes.
2026-07-24 17:31:23 +08:00
Jose Olarte III 11bd45b460 feat(db): switch runtime storage to SQLite repository
Point routes, scheduler, and push service at fcmTokensSqlite so the service no longer reads or writes the JSON store.
2026-07-23 18:55:17 +08:00
Jose Olarte III 5a3c22a4e3 feat(db): add SQLite FCM registration repository
Mirror the JSON repository API on SQLite without switching callers; clarify that SQLite is the storage backend and there is no JSON data migration.
2026-07-23 18:36:10 +08:00
Jose Olarte III fa8ab86cdb feat(db): add SQLite foundation for FCM registration storage
Introduce better-sqlite3 and a schema-init module so we can migrate off JSON storage without changing runtime behavior yet.
2026-07-23 17:44:27 +08:00
trentlarson 55d43a1a20 bump version to 0.1.2 2026-07-16 22:46:09 -06:00
jose c4f34c8882 Merge pull request 'endorser-authentication' (#2) from endorser-authentication into master
Reviewed-on: #2
2026-07-16 12:32:40 +00:00
Jose Olarte III 9634c3422e docs: document Endorser auth flow and ENDORSER_URL
Describe when register/refresh verify with Endorser, keep the testMode bypass notes, and list ENDORSER_URL in the env table.
2026-07-10 21:09:22 +08:00
Jose Olarte III 2343cece5c fix(auth): distinguish Endorser unavailability from auth rejection
Return 503 when Endorser cannot be reached and keep 401 for rejected JWTs, with clearer server-side diagnostics and generic client messages.
2026-07-10 19:06:40 +08:00
Jose Olarte III f2f0732ef5 feat(auth): share Endorser verification across register and refresh
Extract requireEndorserAuth middleware and apply it to /notifications/refresh so both endpoints use the same check while keeping the local test bypass.
2026-07-10 18:58:52 +08:00
Jose Olarte III 764c89c071 feat(auth): require Endorser verification on device registration
Gate /notifications/register on Endorser JWT acceptance while leaving the local testMode bypass unchanged.
2026-07-10 18:44:35 +08:00
Jose Olarte III e5415280bd feat(endorser): add client helper to check JWT via rateLimits
Introduce checkAuth so callers can verify Endorser acceptance of a JWT without dealing with HTTP details.
2026-07-10 18:05:16 +08:00
Jose Olarte III 0758c4192f feat(config): add ENDORSER_URL for upcoming Endorser auth
Expose the Endorser API base URL from the environment (defaulting to production) so later auth work can call it without hardcoding.
2026-07-10 16:09:57 +08:00
Jose Olarte III fcf3fb9aa0 chore: ignore .DS_Store files 2026-07-02 17:14:11 +08:00
trentlarson a99117dc98 bump to v 0.1.1, change default port to 3003, fix Dockerfile to build & run separately 2026-06-28 17:29:21 -06:00
trentlarson 85301135c7 fix type errors 2026-06-28 17:28:54 -06:00
Jose Olarte III 7a2bb88207 feat(config): load .env and log Firebase credential source at startup
Add dotenv via src/env.ts so dev/start read .env before Firebase init,
and log whether credentials come from FIREBASE_SERVICE_ACCOUNT_JSON or ADC.
2026-06-11 17:50:16 +08:00
Jose Olarte III 6261f1baa0 feat(debug): allow local testMode auth on send-wakeup
Move requireAuthOrNotificationLocalTest into shared auth middleware and
apply it only to POST /debug/send-wakeup so local testing matches
register/refresh without changing JWT-authenticated behavior.
2026-06-11 17:24:06 +08:00
trentlarson bb0927ad92 Merge pull request 'Dockerize' (#1) from docker into master
Reviewed-on: #1
2026-06-06 01:31:52 +00:00
trentlarson 2dba6c3597 Merge branch 'master' into docker 2026-06-05 19:30:50 -06:00
Jose Olarte III 6ba7d678c6 feat(notifications): allow local debug register/refresh without JWT
When the Notification Debug Panel sends testMode: true and omits
Authorization, skip requireAuth on /notifications/register and /refresh
and scope devices under a synthetic local-test user id. Requests with
a Bearer token or without testMode still use full JWT auth unchanged.
2026-06-04 20:32:18 +08:00
Jose Olarte III dffb86007e fix(cors): enable preflight for Capacitor WebView requests
Add express cors middleware with reflected origin so Android clients
from https://localhost receive Access-Control-Allow-* headers on
OPTIONS and can proceed with POST requests.
2026-06-04 18:26:22 +08:00
trentlarson a2e5fa0ab9 fix the pnpm build -- 'docker build' now works 2026-06-02 20:37:29 -06:00
trentlarson c010c861b4 attempt a Docker file but it fails on step 5 2026-05-27 20:37:33 -06:00
trentlarson df442df869 add a README.md with basics, and target pnpm instead of npm 2026-05-27 20:02:24 -06:00
Jose Olarte III 03ebe03021 chore: add .env.example for local setup
Document PORT, Firebase credentials, FCM token data dir, and test-local
NODE_ENV so developers can copy the file to .env without guessing vars.
2026-05-24 10:25:45 +08:00
Jose Olarte III f12dd03725 chore(logging): normalize wakeup flow observability with timings and summaries
Standardize console prefixes across scheduler, push, refresh, register,
auth, and debug endpoints. Add pass-level scheduler summaries, elapsed-time
logs, and masked-token-only push failure messages while reducing per-device
noise in scheduler loops.
2026-05-21 19:18:28 +08:00
Jose Olarte III e82c3ae5bc feat(debug): expose nextEligibleAt and structured send-wakeup results
Extend authenticated debug endpoints for local iOS notification testing:
add nextEligibleAt (23h prod / 10m test) to device lookup, return success
and failureReason from send-wakeup with masked tokens only, reuse
resolveOwnedDevice for ownership checks, and standardize [DebugEndpoint] logs.
2026-05-21 18:23:50 +08:00
Jose Olarte III 9764b30aed fix(auth): harden refresh ownership and scheduler after auth migration
Restore /health to { ok: true }. Scope refresh to owned devices via
deviceId/fcmToken, improve register upsert logging, skip legacy rows in
the scheduler with per-token dedupe, and prefer non-legacy rows for push.
2026-05-19 19:53:21 +08:00
Jose Olarte III afbc2e9a57 feat(debug): harden debug routes with auth and user-scoped token access
Add GET /debug/device/:token and POST /debug/send-wakeup behind requireAuth,
scope lookups to the authenticated user (404 otherwise), and mask FCM tokens
in logs via maskToken. Mark routes for further restriction before production.
2026-05-19 19:42:22 +08:00
Jose Olarte III 8e502a2335 feat(notifications): bind device registrations to authenticated user DID
Scope register and refresh to verified JWT identity (req.did). Persist
devices under userId::deviceId, reject client-supplied userId, and dedupe
FCM tokens per user.
2026-05-19 19:02:42 +08:00
Jose Olarte III 4bf57d26fd Add Bearer JWT auth middleware for notification routes
Mirror image-api’s DID JWT verification (src/vc + requireAuth) so
/notifications/* require a valid Authorization header while /health
stays public. Attach req.did, req.jwt, and req.auth for downstream use.
2026-05-19 18:23:41 +08:00
Jose Olarte III fc0cad4f2e feat(register): key devices by deviceId and replace FCM tokens in place
Require deviceId on POST /notifications/register, upsert by deviceId
while preserving lastNotifiedAt and internal id, prune duplicate token
rows, migrate legacy fcmToken-keyed JSON, and add register logs.
Extend StoredRow and Device with deviceId; resolve pushes by scanning
fcmToken.
2026-05-12 21:44:59 +08:00
Jose Olarte III e92ddb7da9 chore(obs): add lightweight console logs for scheduler and push
Log scheduler ticks, refresh requests, dedupe skips by device id,
push attempt/success with token hints, and push failures without
extra sensitive fields.
2026-05-12 18:41:41 +08:00
Jose Olarte III 86d589d0e8 feat(db): track last push time by device id with numeric timestamps
Assign stable ids to stored tokens, migrate legacy ISO lastNotifiedAt
to epoch ms, replace setLastNotifiedAt with db.update, and persist
lastNotifiedAt only after a successful FCM send. Extend Device with
optional lastNotifiedAt (ms).
2026-05-11 21:23:10 +08:00
Jose Olarte III 096f393df9 feat(scheduler): run wake pushes every five minutes
Add db.getAll for registered tokens and tick the interval with
sendPushToDevice per device, with error logging on tick failure.
2026-05-11 18:35:45 +08:00
Jose Olarte III a5266615eb feat(push): send silent iOS wakeups via FCM APNs background
Use apns-push-type background, priority 5, contentAvailable-only aps
payload, and WAKEUP_PING data without alert or sound.
2026-05-11 17:09:01 +08:00
Jose Olarte III 1115929437 feat(push): dedupe FCM sends with 23h / 10m windows
Track lastNotifiedAt on stored tokens, preserve it on register upsert,
and skip messaging.send when inside the production or test-mode window.
2026-05-11 16:56:07 +08:00
Jose Olarte III 2b57ec0e1c feat(notifications): add POST /refresh schedule response
Return shouldNotify and nextNotifications with a 10-minute lookahead
timestamp for the app schedule API.
2026-05-11 15:59:26 +08:00
Jose Olarte III 64ea7d2f98 feat(notifications): persist FCM tokens on POST /register
Add JSON-backed upsert store (data/fcm-tokens.json, optional
FCM_TOKEN_DATA_DIR), validate body fields, and gitignore data/.
2026-05-11 14:52:09 +08:00
Jose Olarte III d311b6a504 feat(fcm): add Firebase Admin SDK and messaging export
Wire firebase-admin with ADC or FIREBASE_SERVICE_ACCOUNT_JSON,
export messaging from src/services/firebase.ts, and load it at
server startup.
2026-05-11 14:47:05 +08:00
29 changed files with 6710 additions and 158 deletions
+16
View File
@@ -0,0 +1,16 @@
# HTTP port (default: 3003)
PORT=3003
# Endorser API base URL (default: https://api.endorser.ch)
# ENDORSER_URL=https://api.endorser.ch
# Firebase Admin: inline service account JSON (one line).
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
# Directory for the SQLite FCM registration database (default: ./data).
# Creates notify.sqlite (plus -wal/-shm while the process is running).
# NOTIFY_DATA_DIR=./data
# Set to "test-local" to bypass ethr JWT expiry verification in local dev only.
# NODE_ENV=test-local
+2
View File
@@ -2,3 +2,5 @@ node_modules/
dist/
.env
*.log
data/
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.3] - 2026.07.30
### Changed
- Persistence changed from file to SQLite
## [0.1.2] - 2026.07.16
### Added
- Validation via Endorser service, with new ENDORSER_URL variable
## [0.1.1] - 2026.06.28
### Added
- Base functionality
+35
View File
@@ -0,0 +1,35 @@
# ---- build stage: install everything, type-check + compile to JS ----
FROM node:22-alpine AS build
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
# ---- runtime stage: prod deps + compiled JS only, no tsx/esbuild ----
FROM node:22-alpine AS runtime
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --prod --frozen-lockfile
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3003
EXPOSE 3003
CMD ["node", "dist/index.js"]
+100
View File
@@ -0,0 +1,100 @@
A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) push notifications to wake up registered devices.
Device registrations are stored in a local **SQLite** database (not JSON).
## Dev
```bash
cp .env.example .env
```
Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`.
Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .`
Optionally set `ENDORSER_URL` if you are not using the default production Endorser API (`https://api.endorser.ch`).
Optionally set `NOTIFY_DATA_DIR` if you want the SQLite database somewhere other than `./data`.
```bash
pnpm install
pnpm run dev
```
The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes.
On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema.
### Authentication
`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT.
**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before.
Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification during local development (this is separate from the `testMode` bypass above).
## Storage
### Database location
| Path | Description |
|---|---|
| `{NOTIFY_DATA_DIR}/notify.sqlite` | Primary SQLite database (default dir: `./data`) |
| `{NOTIFY_DATA_DIR}/notify.sqlite-wal` | WAL journal (present while the process is running) |
| `{NOTIFY_DATA_DIR}/notify.sqlite-shm` | Shared-memory file used with WAL mode |
`NOTIFY_DATA_DIR` defaults to `./data` (relative to the process working directory). The `data/` directory is gitignored.
### Schema (high level)
Table `fcm_registrations` holds one row per registered device:
- Identity: `id`, `user_id`, `device_id`, `fcm_token`, `platform`
- Flags: `test_mode`
- Timestamps: `created_at`, `updated_at`, `last_notified_at`
Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`, `fcm_token`, and `(user_id, fcm_token)`.
The schema is created automatically on startup if the database or tables do not already exist.
### JSON → SQLite
There is **no automatic migration** from the old JSON file (`fcm-tokens.json`). That format is no longer used. If you still have a local `fcm-tokens.json` from earlier development, it is ignored; re-register devices or import data manually if you need it.
### Backup
Persist or back up the SQLite files under `NOTIFY_DATA_DIR`:
1. Prefer stopping the service, then copy `notify.sqlite` (and any `-wal` / `-shm` sidecars if present).
2. Or, while the service is running, copy **all three** files (`notify.sqlite`, `-wal`, `-shm`) together so the backup stays consistent under WAL mode.
3. For Docker, mount a volume at the data directory (or set `NOTIFY_DATA_DIR` to a mounted path) so registrations survive container recreation.
## Production
Runs TypeScript directly via `tsx` (no compile step).
```bash
pnpm install --prod
pnpm start
```
Ensure `NOTIFY_DATA_DIR` points at a durable location (or accept the default `./data` next to the process cwd).
Or with Docker:
```bash
docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
docker run --env-file notify-wakeup-api.env -p 3003:3003 \
-v notify-wakeup-data:/app/data \
notify-wakeup-api
```
Mount a volume over `/app/data` (or whatever path you set with `NOTIFY_DATA_DIR`) so the SQLite database is not lost when the container is replaced.
Required environment variables:
| Variable | Description |
|---|---|
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
| `PORT` | HTTP port (default: `3003`). |
| `ENDORSER_URL` | Endorser API base URL used for auth checks on register/refresh (default: `https://api.endorser.ch`). |
| `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). |
+2440 -138
View File
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -1,20 +1,32 @@
{
"name": "notification-wakeup-service",
"version": "0.1.0",
"version": "0.1.3",
"private": true,
"type": "module",
"packageManager": "pnpm@11.4.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc"
},
"dependencies": {
"express": "^5.1.0"
"@peculiar/asn1-ecc": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"better-sqlite3": "^13.0.1",
"cbor-x": "^1.6.4",
"cors": "^2.8.6",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"dotenv": "^16.6.1",
"express": "^5.2.1",
"firebase-admin": "^13.10.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^22.19.19",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
dependencies:
pnpm.io: ^11
+2560
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
strictDepBuilds: false
onlyBuiltDependencies:
- "@firebase/util"
- better-sqlite3
- cbor-extract
- esbuild
- protobufjs
allowBuilds:
'@firebase/util': set this to true or false
better-sqlite3: set this to true or false
cbor-extract: set this to true or false
esbuild: set this to true or false
protobufjs: set this to true or false
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/**
* SQLite-backed FCM registration repository.
* This is the service's storage backend.
*/
export type StoredRow = {
id: string;
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: number;
};
type DbRow = {
id: string;
user_id: string;
device_id: string;
fcm_token: string;
platform: string;
test_mode: number | null;
created_at: string;
updated_at: string;
last_notified_at: number | null;
};
function toStoredRow(row: DbRow): StoredRow {
return {
id: row.id,
userId: row.user_id,
deviceId: row.device_id,
fcmToken: row.fcm_token,
platform: row.platform,
testMode: row.test_mode === null ? undefined : row.test_mode !== 0,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastNotifiedAt:
row.last_notified_at === null ? undefined : row.last_notified_at,
};
}
function testModeToDb(testMode: boolean | undefined): number | null {
if (testMode === undefined) return null;
return testMode ? 1 : 0;
}
/** Columns required to build a StoredRow. */
const ROW_COLUMNS =
"id, user_id, device_id, fcm_token, platform, test_mode, created_at, updated_at, last_notified_at";
export const db = {
async upsert(row: {
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
updatedAt: Date;
}): Promise<void> {
const connection = getDatabase();
const now = row.updatedAt.toISOString();
// No pre-read: ON CONFLICT preserves id, created_at, and last_notified_at.
const run = connection.transaction(() => {
connection
.prepare(
`
INSERT INTO fcm_registrations (
id, user_id, device_id, fcm_token, platform, test_mode,
created_at, updated_at, last_notified_at
) VALUES (
@id, @user_id, @device_id, @fcm_token, @platform, @test_mode,
@created_at, @updated_at, @last_notified_at
)
ON CONFLICT(user_id, device_id) DO UPDATE SET
fcm_token = excluded.fcm_token,
platform = excluded.platform,
test_mode = excluded.test_mode,
updated_at = excluded.updated_at
`
)
.run({
id: randomUUID(),
user_id: row.userId,
device_id: row.deviceId,
fcm_token: row.fcmToken,
platform: row.platform,
test_mode: testModeToDb(row.testMode),
created_at: now,
updated_at: now,
last_notified_at: null,
});
connection
.prepare(
`
DELETE FROM fcm_registrations
WHERE user_id = ? AND fcm_token = ? AND device_id != ?
`
)
.run(row.userId, row.fcmToken, row.deviceId);
});
run();
},
async getAll(): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(`SELECT ${ROW_COLUMNS} FROM fcm_registrations`)
.all() as DbRow[];
return rows.map(toStoredRow);
},
/** Scheduler iteration; excludes `__legacy__` rows. */
async getAllForScheduler(): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id != '__legacy__'`
)
.all() as DbRow[];
return rows.map(toStoredRow);
},
/**
* Resolve a device owned by userId via deviceId and/or fcmToken.
* When both are given, they must refer to the same row.
*/
async resolveOwnedDevice(
userId: string,
query: { deviceId?: string; fcmToken?: string }
): Promise<StoredRow | undefined> {
const deviceId = query.deviceId?.trim();
const fcmToken = query.fcmToken;
if (deviceId !== undefined && deviceId.length > 0) {
const byDevice = await this.getByDeviceId(userId, deviceId);
if (byDevice === undefined) return undefined;
if (
fcmToken !== undefined &&
fcmToken.length > 0 &&
byDevice.fcmToken !== fcmToken
) {
return undefined;
}
return byDevice;
}
if (fcmToken !== undefined && fcmToken.length > 0) {
return this.getByFcmTokenForUser(userId, fcmToken);
}
return undefined;
},
async getByUserId(userId: string): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id = ?`
)
.all(userId) as DbRow[];
return rows.map(toStoredRow);
},
async getByDeviceId(
userId: string,
deviceId: string
): Promise<StoredRow | undefined> {
const row = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id = ? AND device_id = ?`
)
.get(userId, deviceId) as DbRow | undefined;
return row === undefined ? undefined : toStoredRow(row);
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
// Prefer non-legacy rows; within that pool, newest updated_at wins.
const row = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM fcm_registrations
WHERE fcm_token = ?
ORDER BY (user_id = '__legacy__') ASC, updated_at DESC
LIMIT 1
`
)
.get(fcmToken) as DbRow | undefined;
return row === undefined ? undefined : toStoredRow(row);
},
async getByFcmTokenForUser(
userId: string,
fcmToken: string
): Promise<StoredRow | undefined> {
const row = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM fcm_registrations
WHERE user_id = ? AND fcm_token = ?
LIMIT 1
`
)
.get(userId, fcmToken) as DbRow | undefined;
return row === undefined ? undefined : toStoredRow(row);
},
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
getDatabase()
.prepare(
`UPDATE fcm_registrations SET last_notified_at = ? WHERE id = ?`
)
.run(patch.lastNotifiedAt, id);
},
};
+64
View File
@@ -0,0 +1,64 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
const dataDir =
process.env.NOTIFY_DATA_DIR ?? path.join(process.cwd(), "data");
const dbFile = path.join(dataDir, "notify.sqlite");
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS fcm_registrations (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
fcm_token TEXT NOT NULL,
platform TEXT NOT NULL,
test_mode INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_notified_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_fcm_registrations_user_device
ON fcm_registrations (user_id, device_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_id
ON fcm_registrations (user_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_device_id
ON fcm_registrations (device_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_fcm_token
ON fcm_registrations (fcm_token);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token
ON fcm_registrations (user_id, fcm_token);
`;
let database: Database.Database | null = null;
function ensureSchema(connection: Database.Database): void {
connection.exec(SCHEMA_SQL);
}
/**
* Returns a singleton SQLite connection with schema initialized.
* SQLite is the service's storage backend.
*/
export function getDatabase(): Database.Database {
if (database === null) {
mkdirSync(dataDir, { recursive: true });
database = new Database(dbFile);
database.pragma("journal_mode = WAL");
ensureSchema(database);
}
return database;
}
/** Closes the singleton connection. Intended for tests and graceful shutdown. */
export function closeDatabase(): void {
if (database !== null) {
database.close();
database = null;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { config } from "dotenv";
config();
/** Base URL for the Endorser API server. */
export const ENDORSER_URL =
process.env.ENDORSER_URL ?? "https://api.endorser.ch";
+16 -2
View File
@@ -1,17 +1,31 @@
import "./env.js";
import cors from "cors";
import express from "express";
import "./services/firebase.js";
import { debugRouter } from "./routes/debug.js";
import { notificationsRouter } from "./routes/notifications.js";
import { startScheduler } from "./scheduler.js";
const app = express();
const port = Number(process.env.PORT) || 3000;
const port = Number(process.env.PORT) || 3003;
app.use(
cors({
origin: true,
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
}),
);
app.use(express.json());
// Keep stable for diagnostics tooling compatibility
app.get("/health", (_req, res) => {
res.status(200).json({ status: "ok" });
res.status(200).json({ ok: true });
});
app.use("/notifications", notificationsRouter);
app.use("/debug", debugRouter);
startScheduler();
+169
View File
@@ -0,0 +1,169 @@
import type { NextFunction, Request, Response } from "express";
import { checkAuth } from "../services/endorserClient.js";
import { decodeAndVerifyJwt } from "../vc/index.js";
export type AuthContext = {
did: string;
jwt: string;
};
type ClientErrorBody = {
clientError?: {
message?: string;
code?: string;
};
};
/** Synthetic userId for unauthenticated local debug registrations (testMode). */
const LOCAL_TEST_USER_ID = "__notification_local_test__";
function isNotificationLocalTestBypass(req: Request): boolean {
if (req.headers.authorization?.startsWith("Bearer ")) {
return false;
}
const body = req.body;
return (
body !== null &&
typeof body === "object" &&
(body as { testMode?: unknown }).testMode === true
);
}
function clientErrorMessage(err: unknown): string | undefined {
if (err && typeof err === "object" && "clientError" in err) {
const message = (err as ClientErrorBody).clientError?.message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return undefined;
}
/**
* Express middleware mirroring image-api decodeJwt: Bearer JWT, DID verification,
* attaches req.did / req.jwt / req.auth on success.
*/
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
console.log("[Auth] Authentication failed");
res.status(401).json({
success: false,
message: 'Missing "Bearer JWT" in Authorization header.',
});
return;
}
const token = authHeader.substring("Bearer ".length);
try {
const verified = await decodeAndVerifyJwt(token);
if (!verified.verified) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(
"[Auth] Invalid JWT at",
errorTime + ":",
verified
);
res.status(401).json({
success: false,
message:
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
return;
}
const did = verified.issuer;
req.did = did;
req.jwt = token;
req.auth = { did, jwt: token };
console.log("[Auth] Authenticated user:", did);
next();
} catch (err) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(
"[Auth] Invalid JWT at",
errorTime + ":",
err
);
res.status(401).json({
success: false,
message:
clientErrorMessage(err) ??
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
}
}
export async function requireAuthOrNotificationLocalTest(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
if (isNotificationLocalTestBypass(req)) {
req.did = LOCAL_TEST_USER_ID;
console.log("[Auth] Local notification test bypass");
next();
return;
}
return requireAuth(req, res, next);
}
/**
* When req.jwt is set (normal auth), require Endorser acceptance.
* Local test bypass leaves req.jwt unset and is allowed through unchanged.
*/
export async function requireEndorserAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const jwt = req.jwt;
if (jwt === undefined) {
next();
return;
}
const result = await checkAuth(jwt);
if (result.ok) {
next();
return;
}
const errorTime = new Date().toISOString();
const did = req.did ?? "(unknown)";
if (result.reason === "unavailable") {
console.log("[Auth] Endorser unavailable");
console.error(
"[Auth] Endorser auth check unavailable at",
errorTime + ", did:",
did
);
res.status(503).json({
success: false,
message:
"Authentication service unavailable. See server logs at " + errorTime,
});
return;
}
console.log("[Auth] Endorser verification failed");
console.error(
"[Auth] Endorser rejected JWT at",
errorTime + ", did:",
did
);
res.status(401).json({
success: false,
message: "Unauthorized. See server logs at " + errorTime,
});
}
+8 -1
View File
@@ -1,7 +1,14 @@
export interface Device {
/** Internal row id used for persistence updates. */
id: string;
pushToken: string;
/** Authenticated user DID (from verified JWT). */
userId: string;
/** Client-provided stable physical device identity. */
deviceId: string;
fcmToken: string;
platform: "ios" | "android" | "web";
createdAt: Date;
updatedAt: Date;
/** Epoch ms; set only after a successful push send. */
lastNotifiedAt?: number;
}
+147
View File
@@ -0,0 +1,147 @@
import express, { Router } from "express";
import { db } from "../db/fcmTokensSqlite.js";
import {
requireAuth,
requireAuthOrNotificationLocalTest,
} from "../middleware/auth.js";
import {
computeNextEligibleAt,
sendPushToDevice,
} from "../services/pushService.js";
import { formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
// TODO: Protect this endpoint before production deployment
export const debugRouter: express.Router = Router();
function deviceDebugPayload(row: {
id: string;
deviceId: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: number;
fcmToken: string;
}) {
return {
id: row.id,
deviceId: row.deviceId,
platform: row.platform,
testMode: row.testMode ?? false,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastNotifiedAt: row.lastNotifiedAt,
nextEligibleAt: computeNextEligibleAt(row),
fcmTokenSuffix: maskToken(row.fcmToken),
};
}
function sendWakeupFailureReason(
result: "sent" | "skipped" | "failed"
): string | undefined {
if (result === "sent") return undefined;
if (result === "skipped") {
return "Device was notified within the eligibility threshold";
}
return "FCM send failed";
}
// TODO: Protect this endpoint before production deployment
debugRouter.get("/device/:token", requireAuth, async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const tokenParam = req.params.token;
const fcmToken = decodeURIComponent(
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
);
const suffix = maskToken(fcmToken);
console.log("[DebugEndpoint] Device lookup request, token suffix:", suffix);
const row = await db.resolveOwnedDevice(userId, { fcmToken });
if (row === undefined) {
console.log(
"[DebugEndpoint] Device lookup not found in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
suffix
);
res.status(404).json({ error: "Device not found" });
return;
}
res.json(deviceDebugPayload(row));
console.log(
"[DebugEndpoint] Device lookup completed in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
suffix
);
});
// TODO: Protect this endpoint before production deployment
debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const { fcmToken } = req.body as { fcmToken?: unknown };
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
console.log(
"[DebugEndpoint] Send-wakeup rejected in",
formatElapsedMs(Date.now() - started) + ":",
"fcmToken is required"
);
res.status(400).json({
success: false,
failureReason: "fcmToken is required",
});
return;
}
const suffix = maskToken(fcmToken);
console.log("[DebugEndpoint] Send-wakeup request, token suffix:", suffix);
const row = await db.resolveOwnedDevice(userId, { fcmToken });
if (row === undefined) {
console.log(
"[DebugEndpoint] Send-wakeup rejected in",
formatElapsedMs(Date.now() - started) + ",",
"token suffix:",
suffix + ",",
"reason: Device not found"
);
res.status(404).json({
success: false,
failureReason: "Device not found",
fcmTokenSuffix: suffix,
});
return;
}
const result = await sendPushToDevice(fcmToken);
const success = result === "sent";
const failureReason = sendWakeupFailureReason(result);
res.json({
success,
...(failureReason !== undefined ? { failureReason } : {}),
fcmTokenSuffix: suffix,
});
console.log(
"[DebugEndpoint] Send-wakeup completed in",
formatElapsedMs(Date.now() - started) + ",",
success ? "success" : result + ",",
"token suffix:",
suffix
);
});
+191 -2
View File
@@ -1,7 +1,196 @@
import { Router } from "express";
import express, { Router } from "express";
import { db } from "../db/fcmTokensSqlite.js";
import {
requireAuthOrNotificationLocalTest,
requireEndorserAuth,
} from "../middleware/auth.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
export const notificationsRouter = Router();
export const notificationsRouter: express.Router = Router();
notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" });
});
notificationsRouter.post(
"/refresh",
requireAuthOrNotificationLocalTest,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const { deviceId, fcmToken } = req.body as {
deviceId?: unknown;
fcmToken?: unknown;
};
const canonicalDeviceId =
typeof deviceId === "string" ? deviceId.trim() : undefined;
const token =
typeof fcmToken === "string" && fcmToken.length > 0
? fcmToken
: undefined;
console.log(
"[Refresh] Request received",
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
token !== undefined ? `token suffix=${maskToken(token)}` : ""
);
if (
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
token === undefined
) {
console.log(
"[Refresh] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"deviceId or fcmToken is required"
);
res.status(400).json({ error: "deviceId or fcmToken is required" });
return;
}
const device = await db.resolveOwnedDevice(userId, {
deviceId: canonicalDeviceId,
fcmToken: token,
});
if (device === undefined) {
console.log(
"[Refresh] Device not found in",
formatElapsedMs(Date.now() - started),
canonicalDeviceId !== undefined
? `deviceId=${canonicalDeviceId}`
: "",
token !== undefined ? `token suffix=${maskToken(token)}` : ""
);
res.status(404).json({ error: "Device not found" });
return;
}
const now = Date.now();
res.json({
shouldNotify: true,
nextNotifications: [{ timestamp: now + 600000 }],
});
console.log(
"[Refresh] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + device.deviceId + ",",
"token suffix=" + maskToken(device.fcmToken)
);
}
);
notificationsRouter.post(
"/register",
requireAuthOrNotificationLocalTest,
requireEndorserAuth,
async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
if (
req.body !== null &&
typeof req.body === "object" &&
"userId" in req.body
) {
console.log(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"userId must not be sent in the request body"
);
res.status(400).json({
error: "userId must not be sent in the request body",
});
return;
}
const { deviceId, fcmToken, platform, testMode } = req.body as {
deviceId?: unknown;
fcmToken?: unknown;
platform?: unknown;
testMode?: unknown;
};
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
console.log(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"deviceId is required"
);
res.status(400).json({ error: "deviceId is required" });
return;
}
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
console.log(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"fcmToken is required"
);
res.status(400).json({ error: "fcmToken is required" });
return;
}
if (typeof platform !== "string" || platform.length === 0) {
console.log(
"[Register] Rejected in",
formatElapsedMs(Date.now() - started) + ":",
"platform is required"
);
res.status(400).json({ error: "platform is required" });
return;
}
const canonicalDeviceId = deviceId.trim();
console.log(
"[Register] Request received,",
"deviceId=" + canonicalDeviceId + ",",
"platform=" + platform + ",",
"token suffix=" + maskToken(fcmToken)
);
try {
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
const action =
existing === undefined
? "create"
: existing.fcmToken !== fcmToken
? "update-token"
: "update";
await db.upsert({
userId,
deviceId: canonicalDeviceId,
fcmToken,
platform,
testMode: typeof testMode === "boolean" ? testMode : undefined,
updatedAt: new Date(),
});
res.sendStatus(200);
console.log(
"[Register] Completed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ",",
"action=" + action
);
} catch (err) {
console.error(
"[Register] Failed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ":",
errorMessage(err)
);
res.sendStatus(500);
}
}
);
+54 -4
View File
@@ -1,11 +1,61 @@
import { db } from "./db/fcmTokensSqlite.js";
import { sendPushToDevice } from "./services/pushService.js";
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
let intervalId: ReturnType<typeof setInterval> | undefined;
export function startScheduler(): void {
if (intervalId !== undefined) return;
// TODO: replace with job queue or cron for wake-up checks
intervalId = setInterval(() => {
// placeholder tick
}, 60_000);
intervalId = setInterval(async () => {
const passStarted = Date.now();
console.log("[Scheduler] Pass started");
try {
const devices = await db.getAllForScheduler();
const seenTokens = new Set<string>();
let checked = 0;
let sent = 0;
let skipped = 0;
let failed = 0;
let duplicates = 0;
for (const d of devices) {
if (seenTokens.has(d.fcmToken)) {
duplicates++;
continue;
}
seenTokens.add(d.fcmToken);
checked++;
const result = await sendPushToDevice(d.fcmToken);
if (result === "sent") sent++;
else if (result === "skipped") skipped++;
else failed++;
}
const summaryParts = [
`Checked ${checked} devices`,
`sent ${sent} pushes`,
`skipped ${skipped}`,
];
if (failed > 0) summaryParts.push(`failed ${failed}`);
if (duplicates > 0) {
summaryParts.push(`${duplicates} duplicates ignored`);
}
console.log("[Scheduler]", summaryParts.join(", "));
console.log(
"[Scheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted)
);
} catch (err) {
console.error(
"[Scheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
);
}
}, 5 * 60 * 1000);
}
export function stopScheduler(): void {
+63
View File
@@ -0,0 +1,63 @@
import { ENDORSER_URL } from "../env.js";
import { errorMessage } from "../util/formatElapsed.js";
const RATE_LIMITS_PATH = "/api/report/rateLimits";
export type EndorserAuthResult =
| { ok: true }
| { ok: false; reason: "unauthorized" | "unavailable" };
function rateLimitsUrl(): string {
const base = ENDORSER_URL.replace(/\/+$/, "");
return `${base}${RATE_LIMITS_PATH}`;
}
/**
* Confirms a JWT is accepted by the Endorser server.
*
* Calls GET /api/report/rateLimits with the JWT as a Bearer token.
* Distinguishes auth rejection from Endorser unavailability.
* Does not expose HTTP status or response bodies to callers.
*/
export async function checkAuth(jwt: string): Promise<EndorserAuthResult> {
const url = rateLimitsUrl();
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${jwt}`,
},
});
} catch (err) {
console.error(
"[Endorser] Auth check request failed for",
url + ":",
errorMessage(err)
);
return { ok: false, reason: "unavailable" };
}
if (response.ok) {
return { ok: true };
}
// 5xx: Endorser is up but unhealthy; treat as unavailable.
if (response.status >= 500) {
console.error(
"[Endorser] Auth check unavailable for",
url + ", status",
response.status
);
return { ok: false, reason: "unavailable" };
}
// 4xx: JWT rejected or user not registered on Endorser.
console.error(
"[Endorser] Auth check rejected for",
url + ", status",
response.status
);
return { ok: false, reason: "unauthorized" };
}
+53
View File
@@ -0,0 +1,53 @@
import admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin/app";
import type { Messaging } from "firebase-admin/messaging";
type ServiceAccountJson = ServiceAccount & { project_id?: string };
function serviceAccountProjectId(account: ServiceAccountJson): string | undefined {
if (typeof account.projectId === "string" && account.projectId.length > 0) {
return account.projectId;
}
if (typeof account.project_id === "string" && account.project_id.length > 0) {
return account.project_id;
}
return undefined;
}
function resolveCredential(): admin.credential.Credential {
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
if (json !== undefined && json.trim() !== "") {
let account: ServiceAccountJson;
try {
account = JSON.parse(json) as ServiceAccountJson;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
message
);
throw err;
}
const projectId = serviceAccountProjectId(account);
console.log(
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
);
if (projectId !== undefined) {
console.log("[Firebase] project_id:", projectId);
} else {
console.log("[Firebase] project_id: (not found in service account JSON)");
}
return admin.credential.cert(account);
}
console.log("[Firebase] Credential: Application Default Credentials");
return admin.credential.applicationDefault();
}
if (!admin.apps.length) {
admin.initializeApp({
credential: resolveCredential(),
});
}
export const messaging: Messaging = admin.messaging();
+105 -5
View File
@@ -1,8 +1,108 @@
import type { Device } from "../models/device.js";
import { db, type StoredRow } from "../db/fcmTokensSqlite.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { messaging } from "./firebase.js";
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
const MS_TEST = 10 * 60 * 1000;
export function notifyThresholdMs(testMode?: boolean): number {
return testMode === true ? MS_TEST : MS_PRODUCTION;
}
/** Epoch ms when the device may receive another push (diagnostics only). */
export function computeNextEligibleAt(row: {
lastNotifiedAt?: number;
testMode?: boolean;
}): number {
const threshold = notifyThresholdMs(row.testMode);
if (row.lastNotifiedAt === undefined) {
return Date.now();
}
return row.lastNotifiedAt + threshold;
}
function lastNotifiedMs(row: StoredRow | undefined): number | undefined {
const v = row?.lastNotifiedAt;
if (v === undefined) return undefined;
if (typeof v === "number") return Number.isNaN(v) ? undefined : v;
return undefined;
}
function stringifyData(
payload: Record<string, unknown>
): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(payload)) {
out[k] = v === undefined || v === null ? "" : String(v);
}
return out;
}
/**
* Sends an FCM data message if the token is outside the dedupe window
* (23h production, 10m test).
*/
export async function sendPushToDevice(
_device: Device,
_payload: Record<string, unknown>
): Promise<void> {
// TODO: integrate with push provider (FCM, APNs, etc.)
fcmToken: string,
payload: Record<string, unknown> = {}
): Promise<"sent" | "skipped" | "failed"> {
const suffix = maskToken(fcmToken);
const row = await db.getByFcmToken(fcmToken);
const now = Date.now();
const last = lastNotifiedMs(row);
if (
last !== undefined &&
now - last < notifyThresholdMs(row?.testMode)
) {
return "skipped";
}
const sendStarted = Date.now();
console.log("[Push] Send attempt, token suffix:", suffix);
try {
const data: Record<string, string> = {
...stringifyData(payload),
type: "WAKEUP_PING",
};
await messaging.send({
token: fcmToken,
apns: {
headers: {
"apns-push-type": "background",
"apns-priority": "5",
},
payload: {
aps: {
contentAvailable: true,
},
},
},
data,
});
const persisted = await db.getByFcmToken(fcmToken);
if (persisted !== undefined) {
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
}
console.log(
"[Push] Send completed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix
);
return "sent";
} catch (err) {
console.error(
"[Push] Send failed in",
formatElapsedMs(Date.now() - sendStarted) + ",",
"token suffix:",
suffix + ":",
errorMessage(err)
);
return "failed";
}
}
+14
View File
@@ -0,0 +1,14 @@
declare global {
namespace Express {
interface Request {
/** Authenticated user DID (issuer from verified JWT). */
did?: string;
/** Raw Bearer JWT from the Authorization header. */
jwt?: string;
/** Verified auth context (did + jwt). */
auth?: { did: string; jwt: string };
}
}
}
export {};
+17
View File
@@ -0,0 +1,17 @@
/** Human-readable duration for console logs (e.g. 842ms, 2.1s). */
export function formatElapsedMs(elapsedMs: number): string {
if (elapsedMs < 1000) {
return `${Math.round(elapsedMs)}ms`;
}
return `${(elapsedMs / 1000).toFixed(1)}s`;
}
export function errorMessage(err: unknown): string {
if (err instanceof Error && err.message.length > 0) {
return err.message;
}
if (typeof err === "string" && err.length > 0) {
return err;
}
return "Unknown error";
}
+7
View File
@@ -0,0 +1,7 @@
/** Last 6 characters only — safe for logs and debug responses. */
export function maskToken(token: string): string {
if (token.length <= 6) {
return "******";
}
return token.slice(-6);
}
+46
View File
@@ -0,0 +1,46 @@
import type { DIDResolutionResult } from "did-resolver";
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* Similar code resides in image-api, crowd-funder-for-time-pwa, and endorser-ch.
*/
export const didEthLocalResolver = async (
did: string
): Promise<DIDResolutionResult> => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1];
const publicKeyHex = address;
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSecp256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};
+104
View File
@@ -0,0 +1,104 @@
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
import crypto from "crypto";
import { decode as cborDecode } from "cbor-x";
/**
* similar code is in image-api and crowd-funder-for-time-pwa
*/
export async function verifyPeerSignature(
payloadBytes: Uint8Array,
publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array
) {
const finalSignatureBuffer = unwrapEC2Signature(signatureBytes);
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await crypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"]
);
const verified = await crypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSignatureBuffer,
payloadBytes
);
return verified;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes) as Record<number, unknown>;
if (
jwkObj[1] != 2 ||
jwkObj[3] != -7 ||
jwkObj[-1] != 1 ||
!Array.isArray(jwkObj[-2]) ||
(jwkObj[-2] as Uint8Array).length != 32 ||
!Array.isArray(jwkObj[-3]) ||
(jwkObj[-3] as Uint8Array).length != 32
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2] as Uint8Array),
y: arrayToBase64Url(jwkObj[-3] as Uint8Array),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2] as Uint8Array),
Buffer.from(jwkObj[-3] as Uint8Array),
]);
return { publicKeyJwk, publicKeyBuffer };
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
function unwrapEC2Signature(signature: Uint8Array) {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
return isoUint8ArrayConcat([rBytes, sBytes]);
}
function shouldRemoveLeadingZero(bytes: Uint8Array) {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
function isoUint8ArrayConcat(arrays: Uint8Array[]) {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Verifiable Credential & DID functions (shared pattern with image-api, endorser-ch).
*/
import { verifyJWT } from "did-jwt";
import { Resolver } from "did-resolver";
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
export const TEST_BYPASS_ENV_VALUE = "test-local";
export const ETHR_DID_PREFIX = "did:ethr:";
export const PEER_DID_PREFIX = "did:peer:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
const resolver = new Resolver({
ethr: didEthLocalResolver,
});
export type VerifiedJwt = {
issuer: string;
payload: Record<string, unknown>;
verified: boolean;
};
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
const pieces = jwt.split(".");
const header = JSON.parse(
Buffer.from(pieces[0], "base64url").toString("utf8")
) as Record<string, unknown>;
const payload = JSON.parse(
Buffer.from(pieces[1], "base64url").toString("utf8")
) as Record<string, unknown>;
const issuerDid = payload.iss;
if (!issuerDid || typeof issuerDid !== "string") {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (
issuerDid.startsWith(ETHR_DID_PREFIX) &&
process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE
) {
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
console.log(
"JWT with exp " +
payload.exp +
" has expired but we're in test mode so we'll use a new time."
);
payload.exp = nowEpoch + 100;
}
return { issuer: issuerDid, payload, verified: true };
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await verifyJWT(jwt, { resolver });
return verified as VerifiedJwt;
} catch (e) {
return Promise.reject({
clientError: {
message: `JWT failed verification: ` + e,
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const { claimPayload, verified } = await peerVerifyJwt(
payload,
issuerDid,
pieces[2]
);
return {
issuer: issuerDid,
payload: claimPayload as Record<string, unknown>,
verified,
};
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}
+119
View File
@@ -0,0 +1,119 @@
import crypto from "crypto";
import { multibaseToBytes } from "did-jwt";
import { PEER_DID_PREFIX, TEST_BYPASS_ENV_VALUE } from "./index.js";
import { verifyPeerSignature } from "./didPeer.js";
export async function verifyJwt(
payload: Record<string, unknown>,
issuerDid: string,
signatureString: string
) {
if (!payload.iss) {
return Promise.reject({
clientError: {
message: `JWT is missing an "iss" field.`,
},
});
}
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (!payload.exp) {
return Promise.reject({
clientError: {
message: `JWT with is missing an "exp" field.`,
},
});
}
if (
typeof payload.exp === "number" &&
payload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT with exp ${payload.exp} has expired.`,
},
});
}
const authData = payload.AuthenticationDataB64URL;
const clientData = payload.ClientDataJSONB64URL;
if (typeof authData !== "string" || typeof clientData !== "string") {
return Promise.reject({
clientError: {
message: `JWT with typ == JWANT requires AuthenticationData and ClientDataJSON.`,
},
});
}
const decodedAuthDataBuff = Buffer.from(authData, "base64url");
const decodedClientData = Buffer.from(clientData, "base64url");
let claimPayload = JSON.parse(decodedClientData.toString()) as Record<
string,
unknown
>;
if (claimPayload.challenge) {
claimPayload = JSON.parse(
Buffer.from(claimPayload.challenge as string, "base64url").toString()
) as Record<string, unknown>;
if (!claimPayload.exp) {
claimPayload.exp = payload.exp;
}
if (!claimPayload.iat) {
claimPayload.iat = payload.iat;
}
if (!claimPayload.iss) {
claimPayload.iss = payload.iss;
}
}
if (!claimPayload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge is missing an "exp" field.`,
},
});
}
if (
typeof claimPayload.exp === "number" &&
claimPayload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT client data challenge exp time is past.`,
},
});
}
if (claimPayload.exp !== payload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "exp" field doesn't match the outside payload "exp".`,
},
});
}
if (claimPayload.iss !== payload.iss) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "iss" field doesn't match the outside payload "iss".`,
},
});
}
const hashedClientDataBuff = crypto
.createHash("sha256")
.update(decodedClientData)
.digest();
const preimage = new Uint8Array(
Buffer.concat([decodedAuthDataBuff, hashedClientDataBuff])
);
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
const publicKey = multibaseToBytes(
issuerDid.substring(PEER_DID_MULTIBASE_PREFIX.length)
);
const signature = new Uint8Array(
Buffer.from(signatureString, "base64url")
);
const verified = await verifyPeerSignature(preimage, publicKey, signature);
return { claimPayload, verified };
}