Compare commits
22 Commits
main
...
2dba6c3597
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dba6c3597 | |||
|
|
6ba7d678c6 | ||
|
|
dffb86007e | ||
| a2e5fa0ab9 | |||
| c010c861b4 | |||
| df442df869 | |||
|
|
03ebe03021 | ||
|
|
f12dd03725 | ||
|
|
e82c3ae5bc | ||
|
|
9764b30aed | ||
|
|
afbc2e9a57 | ||
|
|
8e502a2335 | ||
|
|
4bf57d26fd | ||
|
|
fc0cad4f2e | ||
|
|
e92ddb7da9 | ||
|
|
86d589d0e8 | ||
|
|
096f393df9 | ||
|
|
a5266615eb | ||
|
|
1115929437 | ||
|
|
2b57ec0e1c | ||
|
|
64ea7d2f98 | ||
|
|
d311b6a504 |
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# HTTP port (default: 3000)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# 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",...}
|
||||||
|
|
||||||
|
# Local persistence directory for registered FCM tokens (default: ./data)
|
||||||
|
# FCM_TOKEN_DATA_DIR=./data
|
||||||
|
|
||||||
|
# Set to "test-local" to bypass ethr JWT expiry verification in local dev only.
|
||||||
|
# NODE_ENV=test-local
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
data/
|
||||||
|
|||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) push notifications to wake up registered devices.
|
||||||
|
|
||||||
|
## 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 .`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The server starts on `http://localhost:3000` (or the port in `PORT`). Hot-reloads on file changes.
|
||||||
|
|
||||||
|
Set `NODE_ENV=test-local` in `.env` to bypass JWT expiry verification during local development.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Runs TypeScript directly via `tsx` (no compile step).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --prod
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t notification-wakeup-service .
|
||||||
|
docker run -e FIREBASE_SERVICE_ACCOUNT_JSON='...' -p 3000:3000 notification-wakeup-service
|
||||||
|
```
|
||||||
|
|
||||||
|
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: `3000`). |
|
||||||
|
| `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |
|
||||||
2309
package-lock.json
generated
2309
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -3,18 +3,28 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.4.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"build": "tsc"
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0"
|
"@peculiar/asn1-ecc": "^2.7.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.7.0",
|
||||||
|
"cbor-x": "^1.6.4",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"did-jwt": "^7.4.7",
|
||||||
|
"did-resolver": "^4.1.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"firebase-admin": "^13.10.0",
|
||||||
|
"tsx": "^4.22.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.19.19",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2524
pnpm-lock.yaml
generated
Normal file
2524
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
pnpm-workspace.yaml
Normal file
11
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
strictDepBuilds: false
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@firebase/util"
|
||||||
|
- cbor-extract
|
||||||
|
- esbuild
|
||||||
|
- protobufjs
|
||||||
|
allowBuilds:
|
||||||
|
'@firebase/util': 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
|
||||||
290
src/db/fcmTokens.ts
Normal file
290
src/db/fcmTokens.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir =
|
||||||
|
process.env.FCM_TOKEN_DATA_DIR ?? path.join(process.cwd(), "data");
|
||||||
|
const dataFile = path.join(dataDir, "fcm-tokens.json");
|
||||||
|
|
||||||
|
export type StoredRow = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
fcmToken: string;
|
||||||
|
platform: string;
|
||||||
|
testMode?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastNotifiedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedRow = {
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
fcmToken: string;
|
||||||
|
platform: string;
|
||||||
|
testMode?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastNotifiedAt?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function storageKey(userId: string, deviceId: string): string {
|
||||||
|
return `${userId}::${deviceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDeviceRows(
|
||||||
|
key: string,
|
||||||
|
a: StoredRow,
|
||||||
|
b: StoredRow
|
||||||
|
): StoredRow {
|
||||||
|
const primary =
|
||||||
|
new Date(a.updatedAt) >= new Date(b.updatedAt) ? a : b;
|
||||||
|
const lastMs = Math.max(a.lastNotifiedAt ?? 0, b.lastNotifiedAt ?? 0);
|
||||||
|
const created =
|
||||||
|
new Date(a.createdAt) <= new Date(b.createdAt)
|
||||||
|
? a.createdAt
|
||||||
|
: b.createdAt;
|
||||||
|
return {
|
||||||
|
...primary,
|
||||||
|
id: primary.id,
|
||||||
|
userId: primary.userId,
|
||||||
|
deviceId: primary.deviceId,
|
||||||
|
fcmToken: primary.fcmToken,
|
||||||
|
lastNotifiedAt: lastMs > 0 ? lastMs : undefined,
|
||||||
|
createdAt: created,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeParsedRow(
|
||||||
|
mapKey: string,
|
||||||
|
r: ParsedRow,
|
||||||
|
onMutate: () => void
|
||||||
|
): StoredRow {
|
||||||
|
let id = r.id;
|
||||||
|
if (id === undefined || id === "") {
|
||||||
|
id = randomUUID();
|
||||||
|
onMutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastNotifiedAt: number | undefined;
|
||||||
|
if (typeof r.lastNotifiedAt === "string") {
|
||||||
|
const ms = Date.parse(r.lastNotifiedAt);
|
||||||
|
lastNotifiedAt = Number.isNaN(ms) ? undefined : ms;
|
||||||
|
onMutate();
|
||||||
|
} else if (typeof r.lastNotifiedAt === "number") {
|
||||||
|
lastNotifiedAt = Number.isNaN(r.lastNotifiedAt)
|
||||||
|
? undefined
|
||||||
|
: r.lastNotifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = (r.deviceId ?? r.fcmToken ?? mapKey).trim();
|
||||||
|
if (r.deviceId === undefined || r.deviceId === "") {
|
||||||
|
onMutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId = r.userId?.trim();
|
||||||
|
if (userId === undefined || userId === "") {
|
||||||
|
const fromKey = mapKey.includes("::")
|
||||||
|
? mapKey.slice(0, mapKey.indexOf("::"))
|
||||||
|
: "";
|
||||||
|
userId = fromKey || "__legacy__";
|
||||||
|
onMutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
fcmToken: r.fcmToken,
|
||||||
|
platform: r.platform,
|
||||||
|
testMode: r.testMode,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
lastNotifiedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowKey(row: StoredRow): string {
|
||||||
|
if (row.userId === "__legacy__") {
|
||||||
|
return row.deviceId;
|
||||||
|
}
|
||||||
|
return storageKey(row.userId, row.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<Record<string, StoredRow>> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(dataFile, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, ParsedRow>;
|
||||||
|
let dirty = false;
|
||||||
|
const markDirty = (): void => {
|
||||||
|
dirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, StoredRow[]>();
|
||||||
|
|
||||||
|
for (const [mapKey, rawRow] of Object.entries(parsed)) {
|
||||||
|
const row = normalizeParsedRow(mapKey, rawRow, markDirty);
|
||||||
|
const key = rowKey(row);
|
||||||
|
if (mapKey !== key) markDirty();
|
||||||
|
const list = buckets.get(key) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
buckets.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, StoredRow> = {};
|
||||||
|
for (const [key, rows] of buckets) {
|
||||||
|
if (rows.length === 1) {
|
||||||
|
out[key] = rows[0];
|
||||||
|
} else {
|
||||||
|
out[key] = rows
|
||||||
|
.slice(1)
|
||||||
|
.reduce((acc, cur) => mergeDeviceRows(key, acc, cur), rows[0]);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) await save(out);
|
||||||
|
return out;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code = (e as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "ENOENT") return {};
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(records: Record<string, StoredRow>): Promise<void> {
|
||||||
|
await mkdir(dataDir, { recursive: true });
|
||||||
|
const tmp = path.join(dataDir, `.fcm-tokens.${process.pid}.tmp`);
|
||||||
|
const payload = JSON.stringify(records, null, 2);
|
||||||
|
await writeFile(tmp, payload, "utf8");
|
||||||
|
await rename(tmp, dataFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = {
|
||||||
|
async upsert(row: {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
fcmToken: string;
|
||||||
|
platform: string;
|
||||||
|
testMode?: boolean;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
const all = await load();
|
||||||
|
const key = storageKey(row.userId, row.deviceId);
|
||||||
|
const prev = all[key];
|
||||||
|
const now = row.updatedAt.toISOString();
|
||||||
|
all[key] = {
|
||||||
|
id: prev?.id ?? randomUUID(),
|
||||||
|
userId: row.userId,
|
||||||
|
deviceId: row.deviceId,
|
||||||
|
fcmToken: row.fcmToken,
|
||||||
|
platform: row.platform,
|
||||||
|
testMode: row.testMode,
|
||||||
|
updatedAt: now,
|
||||||
|
createdAt: prev?.createdAt ?? now,
|
||||||
|
lastNotifiedAt: prev?.lastNotifiedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const k of [...Object.keys(all)]) {
|
||||||
|
const other = all[k];
|
||||||
|
if (
|
||||||
|
k !== key &&
|
||||||
|
other.userId === row.userId &&
|
||||||
|
other.fcmToken === row.fcmToken
|
||||||
|
) {
|
||||||
|
delete all[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await save(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAll(): Promise<StoredRow[]> {
|
||||||
|
const all = await load();
|
||||||
|
return Object.values(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Scheduler iteration; excludes legacy rows pending migration cleanup. */
|
||||||
|
async getAllForScheduler(): Promise<StoredRow[]> {
|
||||||
|
const all = await load();
|
||||||
|
// TODO: migrate or remove __legacy__ rows after auth rollout
|
||||||
|
return Object.values(all).filter((r) => r.userId !== "__legacy__");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 all = await load();
|
||||||
|
return Object.values(all).filter((r) => r.userId === userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByDeviceId(
|
||||||
|
userId: string,
|
||||||
|
deviceId: string
|
||||||
|
): Promise<StoredRow | undefined> {
|
||||||
|
const all = await load();
|
||||||
|
return all[storageKey(userId, deviceId)];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
|
||||||
|
const all = await load();
|
||||||
|
const matches = Object.values(all).filter((r) => r.fcmToken === fcmToken);
|
||||||
|
if (matches.length === 0) return undefined;
|
||||||
|
const owned = matches.filter((r) => r.userId !== "__legacy__");
|
||||||
|
const pool = owned.length > 0 ? owned : matches;
|
||||||
|
return pool.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
|
)[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByFcmTokenForUser(
|
||||||
|
userId: string,
|
||||||
|
fcmToken: string
|
||||||
|
): Promise<StoredRow | undefined> {
|
||||||
|
const all = await load();
|
||||||
|
return Object.values(all).find(
|
||||||
|
(r) => r.userId === userId && r.fcmToken === fcmToken
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
|
||||||
|
const all = await load();
|
||||||
|
const found = Object.entries(all).find(([, r]) => r.id === id);
|
||||||
|
if (found === undefined) return;
|
||||||
|
const [key, row] = found;
|
||||||
|
all[key] = { ...row, ...patch };
|
||||||
|
await save(all);
|
||||||
|
},
|
||||||
|
};
|
||||||
15
src/index.ts
15
src/index.ts
@@ -1,17 +1,30 @@
|
|||||||
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import "./services/firebase.js";
|
||||||
|
import { debugRouter } from "./routes/debug.js";
|
||||||
import { notificationsRouter } from "./routes/notifications.js";
|
import { notificationsRouter } from "./routes/notifications.js";
|
||||||
import { startScheduler } from "./scheduler.js";
|
import { startScheduler } from "./scheduler.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT) || 3000;
|
const port = Number(process.env.PORT) || 3000;
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: true,
|
||||||
|
methods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Keep stable for diagnostics tooling compatibility
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.status(200).json({ status: "ok" });
|
res.status(200).json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use("/notifications", notificationsRouter);
|
app.use("/notifications", notificationsRouter);
|
||||||
|
app.use("/debug", debugRouter);
|
||||||
|
|
||||||
startScheduler();
|
startScheduler();
|
||||||
|
|
||||||
|
|||||||
88
src/middleware/auth.ts
Normal file
88
src/middleware/auth.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { decodeAndVerifyJwt } from "../vc/index.js";
|
||||||
|
|
||||||
|
export type AuthContext = {
|
||||||
|
did: string;
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientErrorBody = {
|
||||||
|
clientError?: {
|
||||||
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
export interface Device {
|
export interface Device {
|
||||||
|
/** Internal row id used for persistence updates. */
|
||||||
id: string;
|
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";
|
platform: "ios" | "android" | "web";
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
/** Epoch ms; set only after a successful push send. */
|
||||||
|
lastNotifiedAt?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
143
src/routes/debug.ts
Normal file
143
src/routes/debug.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/fcmTokens.js";
|
||||||
|
import { requireAuth } 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 = Router();
|
||||||
|
|
||||||
|
debugRouter.use(requireAuth);
|
||||||
|
|
||||||
|
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", 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 = decodeURIComponent(req.params.token);
|
||||||
|
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", 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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,221 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/fcmTokens.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||||
|
import { maskToken } from "../util/maskToken.js";
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
export const notificationsRouter = Router();
|
export const notificationsRouter = Router();
|
||||||
|
|
||||||
notificationsRouter.get("/", (_req, res) => {
|
notificationsRouter.get("/", (_req, res) => {
|
||||||
res.json({ ok: true, resource: "notifications" });
|
res.json({ ok: true, resource: "notifications" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notificationsRouter.post(
|
||||||
|
"/refresh",
|
||||||
|
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 { 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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,11 +1,61 @@
|
|||||||
|
import { db } from "./db/fcmTokens.js";
|
||||||
|
import { sendPushToDevice } from "./services/pushService.js";
|
||||||
|
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
|
||||||
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
export function startScheduler(): void {
|
export function startScheduler(): void {
|
||||||
if (intervalId !== undefined) return;
|
if (intervalId !== undefined) return;
|
||||||
// TODO: replace with job queue or cron for wake-up checks
|
|
||||||
intervalId = setInterval(() => {
|
intervalId = setInterval(async () => {
|
||||||
// placeholder tick
|
const passStarted = Date.now();
|
||||||
}, 60_000);
|
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 {
|
export function stopScheduler(): void {
|
||||||
|
|||||||
19
src/services/firebase.ts
Normal file
19
src/services/firebase.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import admin from "firebase-admin";
|
||||||
|
import type { ServiceAccount } from "firebase-admin/app";
|
||||||
|
import type { Messaging } from "firebase-admin/messaging";
|
||||||
|
|
||||||
|
function resolveCredential(): admin.credential.Credential {
|
||||||
|
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
|
||||||
|
if (json !== undefined && json.trim() !== "") {
|
||||||
|
return admin.credential.cert(JSON.parse(json) as ServiceAccount);
|
||||||
|
}
|
||||||
|
return admin.credential.applicationDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!admin.apps.length) {
|
||||||
|
admin.initializeApp({
|
||||||
|
credential: resolveCredential(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messaging: Messaging = admin.messaging();
|
||||||
@@ -1,8 +1,108 @@
|
|||||||
import type { Device } from "../models/device.js";
|
import { db, type StoredRow } from "../db/fcmTokens.js";
|
||||||
|
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||||
|
import { maskToken } from "../util/maskToken.js";
|
||||||
|
import { messaging } from "./firebase.js";
|
||||||
|
|
||||||
export async function sendPushToDevice(
|
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
|
||||||
_device: Device,
|
const MS_TEST = 10 * 60 * 1000;
|
||||||
_payload: Record<string, unknown>
|
|
||||||
): Promise<void> {
|
export function notifyThresholdMs(testMode?: boolean): number {
|
||||||
// TODO: integrate with push provider (FCM, APNs, etc.)
|
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(
|
||||||
|
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
src/types/express.d.ts
vendored
Normal file
14
src/types/express.d.ts
vendored
Normal 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
src/util/formatElapsed.ts
Normal file
17
src/util/formatElapsed.ts
Normal 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
src/util/maskToken.ts
Normal file
7
src/util/maskToken.ts
Normal 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
src/vc/did-eth-local-resolver.ts
Normal file
46
src/vc/did-eth-local-resolver.ts
Normal 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
src/vc/didPeer.ts
Normal file
104
src/vc/didPeer.ts
Normal 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
src/vc/index.ts
Normal file
100
src/vc/index.ts
Normal 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
src/vc/passkeyDidPeer.ts
Normal file
119
src/vc/passkeyDidPeer.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user