Compare commits

..

9 Commits

Author SHA1 Message Date
Jose Olarte III
fcf3fb9aa0 chore: ignore .DS_Store files 2026-07-02 17:14:11 +08:00
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
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
bb0927ad92 Merge pull request 'Dockerize' (#1) from docker into master
Reviewed-on: #1
2026-06-06 01:31:52 +00:00
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
14 changed files with 4122 additions and 186 deletions

View File

@@ -1,5 +1,5 @@
# HTTP port (default: 3000) # HTTP port (default: 3003)
PORT=3000 PORT=3003
# Firebase Admin: inline service account JSON (one line). # Firebase Admin: inline service account JSON (one line).
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS). # If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
.env .env
*.log *.log
data/ data/
.DS_Store

View File

@@ -1,17 +1,31 @@
FROM node:22-alpine # ---- build stage: install everything, type-check + compile to JS ----
FROM node:22-alpine AS build
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --prod RUN pnpm install --frozen-lockfile
COPY src ./src
COPY tsconfig.json ./ 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 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 NODE_ENV=production
ENV PORT=3000 ENV PORT=3003
EXPOSE 3000 EXPOSE 3003
CMD ["node", "--import", "tsx/esm", "src/index.ts"] CMD ["node", "dist/index.js"]

View File

@@ -14,7 +14,7 @@ pnpm install
pnpm run dev pnpm run dev
``` ```
The server starts on `http://localhost:3000` (or the port in `PORT`). Hot-reloads on file changes. The server starts on `http://localhost:3003` (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. Set `NODE_ENV=test-local` in `.env` to bypass JWT expiry verification during local development.
@@ -30,8 +30,8 @@ pnpm start
Or with Docker: Or with Docker:
```bash ```bash
docker build -t notification-wakeup-service . docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
docker run -e FIREBASE_SERVICE_ACCOUNT_JSON='...' -p 3000:3000 notification-wakeup-service docker run --env-file notify-wakeup-api.env -p 3003:3003 notify-wakeup-api
``` ```
Required environment variables: Required environment variables:
@@ -39,5 +39,5 @@ Required environment variables:
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. | | `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
| `PORT` | HTTP port (default: `3000`). | | `PORT` | HTTP port (default: `3003`). |
| `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). | | `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |

3786
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "notification-wakeup-service", "name": "notification-wakeup-service",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@11.4.0", "packageManager": "pnpm@11.4.0",
@@ -13,15 +13,18 @@
"@peculiar/asn1-ecc": "^2.7.0", "@peculiar/asn1-ecc": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0", "@peculiar/asn1-schema": "^2.7.0",
"cbor-x": "^1.6.4", "cbor-x": "^1.6.4",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"did-resolver": "^4.1.0", "did-resolver": "^4.1.0",
"express": "^5.2.1", "express": "^5.2.1",
"firebase-admin": "^13.10.0", "firebase-admin": "^13.10.0"
"tsx": "^4.22.3"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/cors": "^2.8.19",
"tsx": "^4.22.3",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

43
pnpm-lock.yaml generated
View File

@@ -17,28 +17,37 @@ importers:
cbor-x: cbor-x:
specifier: ^1.6.4 specifier: ^1.6.4
version: 1.6.4 version: 1.6.4
cors:
specifier: ^2.8.6
version: 2.8.6
did-jwt: did-jwt:
specifier: ^7.4.7 specifier: ^7.4.7
version: 7.4.7 version: 7.4.7
did-resolver: did-resolver:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
dotenv:
specifier: ^16.6.1
version: 16.6.1
express: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
firebase-admin: firebase-admin:
specifier: ^13.10.0 specifier: ^13.10.0
version: 13.10.0 version: 13.10.0
tsx:
specifier: ^4.22.3
version: 4.22.3
devDependencies: devDependencies:
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express': '@types/express':
specifier: ^5.0.6 specifier: ^5.0.6
version: 5.0.6 version: 5.0.6
'@types/node': '@types/node':
specifier: ^22.19.19 specifier: ^22.19.19
version: 22.19.19 version: 22.19.19
tsx:
specifier: ^4.22.3
version: 4.22.3
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@@ -382,6 +391,9 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/express-serve-static-core@5.1.1': '@types/express-serve-static-core@5.1.1':
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
@@ -530,6 +542,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
data-uri-to-buffer@4.0.1: data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -561,6 +577,10 @@ packages:
did-resolver@4.1.0: did-resolver@4.1.0:
resolution: {integrity: sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==} resolution: {integrity: sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -926,6 +946,10 @@ packages:
resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==}
hasBin: true hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-hash@3.0.0: object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -1480,6 +1504,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.19 '@types/node': 22.19.19
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.19
'@types/express-serve-static-core@5.1.1': '@types/express-serve-static-core@5.1.1':
dependencies: dependencies:
'@types/node': 22.19.19 '@types/node': 22.19.19
@@ -1657,6 +1685,11 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}
debug@4.4.3: debug@4.4.3:
@@ -1685,6 +1718,8 @@ snapshots:
did-resolver@4.1.0: {} did-resolver@4.1.0: {}
dotenv@16.6.1: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -2193,6 +2228,8 @@ snapshots:
detect-libc: 2.1.2 detect-libc: 2.1.2
optional: true optional: true
object-assign@4.1.1: {}
object-hash@3.0.0: object-hash@3.0.0:
optional: true optional: true

View File

@@ -4,3 +4,8 @@ onlyBuiltDependencies:
- cbor-extract - cbor-extract
- esbuild - esbuild
- protobufjs - 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

3
src/env.ts Normal file
View File

@@ -0,0 +1,3 @@
import { config } from "dotenv";
config();

View File

@@ -1,3 +1,5 @@
import "./env.js";
import cors from "cors";
import express from "express"; import express from "express";
import "./services/firebase.js"; import "./services/firebase.js";
import { debugRouter } from "./routes/debug.js"; import { debugRouter } from "./routes/debug.js";
@@ -5,7 +7,15 @@ 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) || 3003;
app.use(
cors({
origin: true,
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
}),
);
app.use(express.json()); app.use(express.json());

View File

@@ -13,6 +13,21 @@ type ClientErrorBody = {
}; };
}; };
/** 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 { function clientErrorMessage(err: unknown): string | undefined {
if (err && typeof err === "object" && "clientError" in err) { if (err && typeof err === "object" && "clientError" in err) {
const message = (err as ClientErrorBody).clientError?.message; const message = (err as ClientErrorBody).clientError?.message;
@@ -86,3 +101,17 @@ export async function requireAuth(
}); });
} }
} }
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);
}

View File

@@ -1,6 +1,9 @@
import { Router } from "express"; import express, { Router } from "express";
import { db } from "../db/fcmTokens.js"; import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js"; import {
requireAuth,
requireAuthOrNotificationLocalTest,
} from "../middleware/auth.js";
import { import {
computeNextEligibleAt, computeNextEligibleAt,
sendPushToDevice, sendPushToDevice,
@@ -9,9 +12,7 @@ import { formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js"; import { maskToken } from "../util/maskToken.js";
// TODO: Protect this endpoint before production deployment // TODO: Protect this endpoint before production deployment
export const debugRouter = Router(); export const debugRouter: express.Router = Router();
debugRouter.use(requireAuth);
function deviceDebugPayload(row: { function deviceDebugPayload(row: {
id: string; id: string;
@@ -47,7 +48,7 @@ function sendWakeupFailureReason(
} }
// TODO: Protect this endpoint before production deployment // TODO: Protect this endpoint before production deployment
debugRouter.get("/device/:token", async (req, res) => { debugRouter.get("/device/:token", requireAuth, async (req, res) => {
const started = Date.now(); const started = Date.now();
const userId = req.did; const userId = req.did;
if (userId === undefined) { if (userId === undefined) {
@@ -55,7 +56,10 @@ debugRouter.get("/device/:token", async (req, res) => {
return; return;
} }
const fcmToken = decodeURIComponent(req.params.token); const tokenParam = req.params.token;
const fcmToken = decodeURIComponent(
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
);
const suffix = maskToken(fcmToken); const suffix = maskToken(fcmToken);
console.log("[DebugEndpoint] Device lookup request, token suffix:", suffix); console.log("[DebugEndpoint] Device lookup request, token suffix:", suffix);
@@ -81,7 +85,7 @@ debugRouter.get("/device/:token", async (req, res) => {
}); });
// TODO: Protect this endpoint before production deployment // TODO: Protect this endpoint before production deployment
debugRouter.post("/send-wakeup", async (req, res) => { debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => {
const started = Date.now(); const started = Date.now();
const userId = req.did; const userId = req.did;
if (userId === undefined) { if (userId === undefined) {

View File

@@ -1,181 +1,191 @@
import { Router } from "express"; import express, { Router } from "express";
import { db } from "../db/fcmTokens.js"; import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js"; import { requireAuthOrNotificationLocalTest } from "../middleware/auth.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js"; import { maskToken } from "../util/maskToken.js";
export const notificationsRouter = Router(); export const notificationsRouter: express.Router = Router();
notificationsRouter.get("/", (_req, res) => { notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" }); res.json({ ok: true, resource: "notifications" });
}); });
notificationsRouter.post("/refresh", requireAuth, async (req, res) => { notificationsRouter.post(
const started = Date.now(); "/refresh",
const userId = req.did; requireAuthOrNotificationLocalTest,
if (userId === undefined) { async (req, res) => {
res.status(401).json({ success: false, message: "Unauthorized" }); const started = Date.now();
return; const userId = req.did;
} if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const { deviceId, fcmToken } = req.body as { const { deviceId, fcmToken } = req.body as {
deviceId?: unknown; deviceId?: unknown;
fcmToken?: unknown; fcmToken?: unknown;
}; };
const canonicalDeviceId = const canonicalDeviceId =
typeof deviceId === "string" ? deviceId.trim() : undefined; typeof deviceId === "string" ? deviceId.trim() : undefined;
const token = const token =
typeof fcmToken === "string" && fcmToken.length > 0 ? fcmToken : undefined; 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( console.log(
"[Refresh] Rejected in", "[Refresh] Request received",
formatElapsedMs(Date.now() - started) + ":", canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
"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)}` : "" token !== undefined ? `token suffix=${maskToken(token)}` : ""
); );
res.status(404).json({ error: "Device not found" });
return;
}
const now = Date.now(); if (
res.json({ (canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
shouldNotify: true, token === undefined
nextNotifications: [{ timestamp: now + 600000 }], ) {
}); console.log(
console.log( "[Refresh] Rejected in",
"[Refresh] Completed in", formatElapsedMs(Date.now() - started) + ":",
formatElapsedMs(Date.now() - started) + ",", "deviceId or fcmToken is required"
"deviceId=" + device.deviceId + ",", );
"token suffix=" + maskToken(device.fcmToken) res.status(400).json({ error: "deviceId or fcmToken is required" });
); return;
}); }
notificationsRouter.post("/register", requireAuth, async (req, res) => { const device = await db.resolveOwnedDevice(userId, {
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, deviceId: canonicalDeviceId,
fcmToken, fcmToken: token,
platform, });
testMode: typeof testMode === "boolean" ? testMode : undefined,
updatedAt: new Date(), 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 }],
}); });
res.sendStatus(200);
console.log( console.log(
"[Register] Completed in", "[Refresh] Completed in",
formatElapsedMs(Date.now() - started) + ",", formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ",", "deviceId=" + device.deviceId + ",",
"action=" + action "token suffix=" + maskToken(device.fcmToken)
); );
} catch (err) {
console.error(
"[Register] Failed in",
formatElapsedMs(Date.now() - started) + ",",
"deviceId=" + canonicalDeviceId + ":",
errorMessage(err)
);
res.sendStatus(500);
} }
}); );
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);
}
}
);

View File

@@ -2,11 +2,45 @@ import admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin/app"; import type { ServiceAccount } from "firebase-admin/app";
import type { Messaging } from "firebase-admin/messaging"; 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 { function resolveCredential(): admin.credential.Credential {
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON; const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
if (json !== undefined && json.trim() !== "") { if (json !== undefined && json.trim() !== "") {
return admin.credential.cert(JSON.parse(json) as ServiceAccount); 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(); return admin.credential.applicationDefault();
} }