refactor: remove deprecated push notification API routes and related logic
This commit is contained in:
@@ -1,96 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { subscriptionStore } from '@/lib/push/store';
|
|
||||||
import { sendFcmPush } from '@/lib/push/fcm';
|
|
||||||
import { isValidSubscriptionId } from '@/lib/push/validation';
|
|
||||||
import type { JmapPushBody } from '@/lib/push/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/push/jmap/:id
|
|
||||||
*
|
|
||||||
* Destination configured on JMAP PushSubscription. Spec (RFC 8620 §7.2)
|
|
||||||
* allows two body shapes:
|
|
||||||
*
|
|
||||||
* { "@type": "PushVerification", pushSubscriptionId, verificationCode }
|
|
||||||
* { "@type": "StateChange", changed: { [accountId]: { [type]: state } } }
|
|
||||||
*
|
|
||||||
* PushVerification is terminated here — cached against the subscription so
|
|
||||||
* the client can poll it. StateChange is fanned out via FCM so the mobile
|
|
||||||
* app wakes and re-fetches with its own credentials.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function getAllowedOrigin(): string | null {
|
|
||||||
const raw = process.env.PUSH_ALLOWED_JMAP_ORIGIN?.trim();
|
|
||||||
return raw ? raw : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSharedSecret(): string | null {
|
|
||||||
const raw = process.env.PUSH_SHARED_SECRET?.trim();
|
|
||||||
return raw ? raw : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
if (!isValidSubscriptionId(id)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedOrigin = getAllowedOrigin();
|
|
||||||
if (allowedOrigin) {
|
|
||||||
const origin = request.headers.get('origin') ?? '';
|
|
||||||
if (origin && origin !== allowedOrigin) {
|
|
||||||
return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedSecret = getSharedSecret();
|
|
||||||
if (sharedSecret) {
|
|
||||||
// Stalwart supports a custom `Authorization` header on the push URL via
|
|
||||||
// PushSubscription.keys/headers — a shared secret is the strongest
|
|
||||||
// authentication a stateless relay can enforce.
|
|
||||||
const presented = request.headers.get('x-push-secret') ?? '';
|
|
||||||
if (presented !== sharedSecret) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await subscriptionStore.get(id);
|
|
||||||
if (!record) {
|
|
||||||
return NextResponse.json({ error: 'Unknown subscription' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json().catch(() => null)) as JmapPushBody | null;
|
|
||||||
if (!body || typeof body['@type'] !== 'string') {
|
|
||||||
return NextResponse.json({ error: 'Invalid JMAP push body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body['@type'] === 'PushVerification') {
|
|
||||||
record.verificationCode = body.verificationCode;
|
|
||||||
await subscriptionStore.put(id, record);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body['@type'] === 'StateChange') {
|
|
||||||
const result = await sendFcmPush(record, body);
|
|
||||||
record.lastPushAt = Date.now();
|
|
||||||
await subscriptionStore.put(id, record);
|
|
||||||
if (result.unregistered) {
|
|
||||||
// FCM told us the token is no longer valid — drop the subscription
|
|
||||||
// so the device re-registers on next launch.
|
|
||||||
await subscriptionStore.delete(id);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: result.ok });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Unsupported JMAP push type' }, { status: 400 });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('push: jmap handler failed', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { subscriptionStore } from '@/lib/push/store';
|
|
||||||
import { isValidSubscriptionId } from '@/lib/push/validation';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/push/register/:id
|
|
||||||
*
|
|
||||||
* Client tears down its relay mapping on logout / uninstall. Idempotent —
|
|
||||||
* deleting a non-existent id still returns ok.
|
|
||||||
*/
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
if (!isValidSubscriptionId(id)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
|
|
||||||
}
|
|
||||||
await subscriptionStore.delete(id);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('push: unregister failed', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { subscriptionStore } from '@/lib/push/store';
|
|
||||||
import { isValidFcmToken, isValidSubscriptionId } from '@/lib/push/validation';
|
|
||||||
import type { SubscriptionRecord } from '@/lib/push/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/push/register
|
|
||||||
* Body: { subscriptionId, fcmToken, accountLabel? }
|
|
||||||
*
|
|
||||||
* Called by the mobile app once Firebase has issued an FCM registration
|
|
||||||
* token. The relay stores only the opaque token — no user credentials.
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = (await request.json().catch(() => null)) as {
|
|
||||||
subscriptionId?: unknown;
|
|
||||||
fcmToken?: unknown;
|
|
||||||
accountLabel?: unknown;
|
|
||||||
} | null;
|
|
||||||
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
|
||||||
|
|
||||||
const { subscriptionId, fcmToken, accountLabel } = body;
|
|
||||||
if (!isValidSubscriptionId(subscriptionId)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (!isValidFcmToken(fcmToken)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid fcmToken' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve verificationCode if a previous record exists — the JMAP server
|
|
||||||
// may have already POSTed PushVerification before the app re-registers.
|
|
||||||
const existing = await subscriptionStore.get(subscriptionId);
|
|
||||||
|
|
||||||
const record: SubscriptionRecord = {
|
|
||||||
fcmToken,
|
|
||||||
verificationCode: existing?.verificationCode ?? null,
|
|
||||||
createdAt: existing?.createdAt ?? Date.now(),
|
|
||||||
lastPushAt: existing?.lastPushAt ?? null,
|
|
||||||
accountLabel:
|
|
||||||
typeof accountLabel === 'string' ? accountLabel.slice(0, 120) : undefined,
|
|
||||||
};
|
|
||||||
await subscriptionStore.put(subscriptionId, record);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('push: register failed', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { subscriptionStore } from '@/lib/push/store';
|
|
||||||
import { isValidSubscriptionId } from '@/lib/push/validation';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/push/verify/:id
|
|
||||||
*
|
|
||||||
* Mobile app polls this after creating a JMAP PushSubscription. When the
|
|
||||||
* JMAP server POSTs a PushVerification to /api/push/jmap/:id we stash the
|
|
||||||
* code on the record; the client then PATCHes the subscription with it and
|
|
||||||
* activation completes (RFC 8620 §7.2.2).
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
if (!isValidSubscriptionId(id)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const record = await subscriptionStore.get(id);
|
|
||||||
if (!record) {
|
|
||||||
return NextResponse.json({ error: 'Unknown subscription' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ verificationCode: record.verificationCode ?? null });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('push: verify poll failed', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-158
@@ -1,158 +0,0 @@
|
|||||||
import crypto from 'node:crypto';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { StateChange } from './types';
|
|
||||||
import type { SubscriptionRecord } from './types';
|
|
||||||
|
|
||||||
interface ServiceAccount {
|
|
||||||
type: string;
|
|
||||||
project_id: string;
|
|
||||||
private_key_id: string;
|
|
||||||
private_key: string;
|
|
||||||
client_email: string;
|
|
||||||
token_uri: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedAccount: ServiceAccount | null = null;
|
|
||||||
let cachedAccessToken: { token: string; expiresAt: number } | null = null;
|
|
||||||
|
|
||||||
async function loadServiceAccount(): Promise<ServiceAccount> {
|
|
||||||
if (cachedAccount) return cachedAccount;
|
|
||||||
|
|
||||||
const inline = process.env.FCM_SERVICE_ACCOUNT_JSON;
|
|
||||||
if (inline && inline.trim().startsWith('{')) {
|
|
||||||
cachedAccount = JSON.parse(inline) as ServiceAccount;
|
|
||||||
return cachedAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = inline && inline.trim().length > 0
|
|
||||||
? inline
|
|
||||||
: path.join(process.env.PUSH_DATA_DIR ?? './data/push', 'fcm-service-account.json');
|
|
||||||
const raw = await fs.readFile(filePath, 'utf8');
|
|
||||||
cachedAccount = JSON.parse(raw) as ServiceAccount;
|
|
||||||
return cachedAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64url(buf: Buffer | string): string {
|
|
||||||
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
||||||
return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mintAccessToken(): Promise<string> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (cachedAccessToken && cachedAccessToken.expiresAt - 60_000 > now) {
|
|
||||||
return cachedAccessToken.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await loadServiceAccount();
|
|
||||||
const iat = Math.floor(now / 1000);
|
|
||||||
const exp = iat + 3600;
|
|
||||||
const header = { alg: 'RS256', typ: 'JWT', kid: account.private_key_id };
|
|
||||||
const claim = {
|
|
||||||
iss: account.client_email,
|
|
||||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
|
||||||
aud: account.token_uri ?? 'https://oauth2.googleapis.com/token',
|
|
||||||
iat,
|
|
||||||
exp,
|
|
||||||
};
|
|
||||||
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claim))}`;
|
|
||||||
const signature = crypto
|
|
||||||
.createSign('RSA-SHA256')
|
|
||||||
.update(signingInput)
|
|
||||||
.sign(account.private_key);
|
|
||||||
const jwt = `${signingInput}.${base64url(signature)}`;
|
|
||||||
|
|
||||||
const res = await fetch(account.token_uri ?? 'https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
||||||
assertion: jwt,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`FCM oauth2 token failed: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { access_token: string; expires_in: number };
|
|
||||||
cachedAccessToken = {
|
|
||||||
token: body.access_token,
|
|
||||||
expiresAt: now + body.expires_in * 1000,
|
|
||||||
};
|
|
||||||
return body.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FcmSendResult {
|
|
||||||
ok: boolean;
|
|
||||||
status: number;
|
|
||||||
unregistered: boolean;
|
|
||||||
body?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a data message via FCM HTTP v1. Returns `unregistered: true` if the
|
|
||||||
* token was rejected with UNREGISTERED / NOT_FOUND — callers should delete
|
|
||||||
* the subscription in that case.
|
|
||||||
*/
|
|
||||||
export async function sendFcmPush(
|
|
||||||
record: SubscriptionRecord,
|
|
||||||
change: StateChange,
|
|
||||||
): Promise<FcmSendResult> {
|
|
||||||
const account = await loadServiceAccount();
|
|
||||||
const accessToken = await mintAccessToken();
|
|
||||||
const hasEmail = Boolean(change.changed && Object.values(change.changed).some((types) => 'Email' in types));
|
|
||||||
|
|
||||||
const title = hasEmail ? 'New mail' : 'Mailbox updated';
|
|
||||||
const body = record.accountLabel ?? 'Tap to open Bulwark';
|
|
||||||
|
|
||||||
const message = {
|
|
||||||
message: {
|
|
||||||
token: record.fcmToken,
|
|
||||||
android: {
|
|
||||||
priority: 'HIGH',
|
|
||||||
notification: {
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
channel_id: 'bulwark_mail',
|
|
||||||
default_sound: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
kind: 'jmap-state-change',
|
|
||||||
changed: JSON.stringify(change.changed ?? {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.project_id)}/messages:send`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${accessToken}`,
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(message),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawBody = await res.text();
|
|
||||||
let parsed: unknown = rawBody;
|
|
||||||
try {
|
|
||||||
parsed = rawBody.length > 0 ? JSON.parse(rawBody) : null;
|
|
||||||
} catch {
|
|
||||||
// keep raw text
|
|
||||||
}
|
|
||||||
|
|
||||||
const errStatus =
|
|
||||||
typeof parsed === 'object' && parsed && 'error' in parsed
|
|
||||||
? ((parsed as { error?: { status?: string } }).error?.status ?? '')
|
|
||||||
: '';
|
|
||||||
const unregistered = res.status === 404 || errStatus === 'UNREGISTERED' || errStatus === 'NOT_FOUND';
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: res.ok,
|
|
||||||
status: res.status,
|
|
||||||
unregistered,
|
|
||||||
body: parsed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import type { SubscriptionRecord } from './types';
|
|
||||||
|
|
||||||
// JMAP subscriptions default to "no expiry" but servers may expire at will.
|
|
||||||
// We evict mappings 30d past last activity; active devices re-register on
|
|
||||||
// every app launch so real users never age out.
|
|
||||||
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
// How long a verification code sits waiting for the client to poll it.
|
|
||||||
const VERIFICATION_TTL_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
function getPushDir(): string {
|
|
||||||
return process.env.PUSH_DATA_DIR || path.join(process.cwd(), 'data', 'push');
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscriptionsPath(): string {
|
|
||||||
return path.join(getPushDir(), 'subscriptions.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubscriptionsFile {
|
|
||||||
records: Record<string, SubscriptionRecord>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SubscriptionStore {
|
|
||||||
private cache: Record<string, SubscriptionRecord> = {};
|
|
||||||
private loaded = false;
|
|
||||||
private writeQueue: Promise<void> = Promise.resolve();
|
|
||||||
|
|
||||||
async load(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const raw = await readFile(subscriptionsPath(), 'utf-8');
|
|
||||||
const parsed = JSON.parse(raw) as SubscriptionsFile;
|
|
||||||
this.cache = parsed.records ?? {};
|
|
||||||
this.evictExpired();
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
this.cache = {};
|
|
||||||
} else {
|
|
||||||
logger.warn('push: failed to read subscriptions', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
this.cache = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureLoaded(): Promise<void> {
|
|
||||||
if (!this.loaded) await this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(id: string): Promise<SubscriptionRecord | null> {
|
|
||||||
await this.ensureLoaded();
|
|
||||||
const record = this.cache[id];
|
|
||||||
if (!record) return null;
|
|
||||||
if (this.isExpired(record)) {
|
|
||||||
delete this.cache[id];
|
|
||||||
await this.persist();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(id: string, record: SubscriptionRecord): Promise<void> {
|
|
||||||
await this.ensureLoaded();
|
|
||||||
this.cache[id] = record;
|
|
||||||
await this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await this.ensureLoaded();
|
|
||||||
if (id in this.cache) {
|
|
||||||
delete this.cache[id];
|
|
||||||
await this.persist();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isExpired(record: SubscriptionRecord): boolean {
|
|
||||||
const age = Date.now() - (record.lastPushAt ?? record.createdAt);
|
|
||||||
return age > TTL_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private evictExpired(): void {
|
|
||||||
for (const [id, record] of Object.entries(this.cache)) {
|
|
||||||
if (this.isExpired(record)) delete this.cache[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private persist(): Promise<void> {
|
|
||||||
// Serialize writes so concurrent requests don't clobber each other.
|
|
||||||
const next = this.writeQueue.then(() => this.flush());
|
|
||||||
this.writeQueue = next.catch(() => undefined);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async flush(): Promise<void> {
|
|
||||||
const dir = getPushDir();
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
const target = subscriptionsPath();
|
|
||||||
const tmp = target + '.tmp';
|
|
||||||
const payload: SubscriptionsFile = { records: this.cache };
|
|
||||||
await writeFile(tmp, JSON.stringify(payload, null, 2), 'utf-8');
|
|
||||||
await rename(tmp, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const subscriptionStore = new SubscriptionStore();
|
|
||||||
|
|
||||||
export { VERIFICATION_TTL_MS };
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// Types shared between the relay routes and the store. Kept in a leaf module
|
|
||||||
// so both server code and admin UIs can import without pulling `node:fs`.
|
|
||||||
|
|
||||||
export interface SubscriptionRecord {
|
|
||||||
fcmToken: string;
|
|
||||||
verificationCode: string | null;
|
|
||||||
createdAt: number;
|
|
||||||
lastPushAt: number | null;
|
|
||||||
accountLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PushVerification {
|
|
||||||
'@type': 'PushVerification';
|
|
||||||
pushSubscriptionId: string;
|
|
||||||
verificationCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StateChange {
|
|
||||||
'@type': 'StateChange';
|
|
||||||
changed: Record<string, Record<string, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JmapPushBody = PushVerification | StateChange;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export function isValidSubscriptionId(id: unknown): id is string {
|
|
||||||
return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidFcmToken(value: unknown): value is string {
|
|
||||||
// FCM registration tokens are opaque. In practice they're ~140–250 chars of
|
|
||||||
// [A-Za-z0-9:_-]. Be permissive on length; strict on charset to block
|
|
||||||
// obvious garbage without rejecting future token formats.
|
|
||||||
return typeof value === 'string' && value.length >= 64 && value.length <= 4096 && /^[A-Za-z0-9:_-]+$/.test(value);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user