reset-password accepted any valid signed jwt as a reset token, including a 30-day session token — its sibling verify-token.ts already gated on purpose === 'reset-password' and this handler did not. forgot-password mints that claim, so the gate costs the legitimate flow nothing. notify's DELETE /_officer/devices/:token deleted by token with no user predicate: a token is the address of a device, not a secret, so any account holding the notify capability could deregister another's device. deletePushDevice now takes an optional userId — the route passes it, the APNs/FCM dead-token paths deliberately do not. POST /_officer/notify let a request body's userId override the proxy-injected X-Officer-User. The header now wins where present, which is what separates a signed-in browser from a loopback producer that has no session to speak from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
import { upsertPushDevice, getPushDevices, deletePushDevice } from 'officerdb';
|
|
|
|
// Device registration, reached through the platform's authenticated proxy.
|
|
//
|
|
// The caller comes from X-Officer-User, injected by createSidecarProxy and trusted because this server
|
|
// binds loopback only. Nothing else can reach it.
|
|
//
|
|
// Every route here is scoped to that id, including the DELETE — which takes a token from the URL, and a
|
|
// token is the address of a device rather than a secret.
|
|
|
|
const PLATFORMS = new Set(['ios', 'android']);
|
|
const ENVIRONMENTS = new Set(['production', 'sandbox']);
|
|
|
|
type RegisterBody = {
|
|
token?: string;
|
|
platform?: string;
|
|
environment?: string;
|
|
bundleId?: string;
|
|
appSlug?: string;
|
|
};
|
|
|
|
function ownerId(req: Request): number | null {
|
|
const id = Number(req.headers.get('X-Officer-User'));
|
|
return Number.isFinite(id) && id > 0 ? id : null;
|
|
}
|
|
|
|
export async function handleDeviceRoute(req: Request, url: URL): Promise<Response | null> {
|
|
const userId = ownerId(req);
|
|
if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
|
|
|
if (url.pathname === '/_officer/devices' && req.method === 'GET') {
|
|
const devices = await getPushDevices(userId, url.searchParams.get('appSlug') ?? undefined);
|
|
// Never echo tokens back in full: they are the address of a device, and this response goes to a
|
|
// client. Enough to identify a row, not enough to push to it.
|
|
return Response.json({
|
|
devices: devices.map((d) => ({
|
|
id: d.id,
|
|
platform: d.platform,
|
|
environment: d.environment,
|
|
bundleId: d.bundleId,
|
|
appSlug: d.appSlug,
|
|
tokenSuffix: d.token.slice(-8),
|
|
lastSeenAt: d.lastSeenAt,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/_officer/devices' && req.method === 'POST') {
|
|
let body: RegisterBody;
|
|
try {
|
|
body = (await req.json()) as RegisterBody;
|
|
} catch {
|
|
return Response.json({ error: 'invalid json' }, { status: 400 });
|
|
}
|
|
|
|
const { token, platform, bundleId, appSlug } = body;
|
|
const environment = body.environment ?? 'production';
|
|
|
|
if (!token || !platform || !bundleId || !appSlug) {
|
|
return Response.json({ error: 'token, platform, bundleId and appSlug are required' }, { status: 400 });
|
|
}
|
|
if (!PLATFORMS.has(platform)) {
|
|
return Response.json({ error: "platform must be 'ios' or 'android'" }, { status: 400 });
|
|
}
|
|
// Rejected rather than defaulted: an iOS token from a debug build fails against production APNs with
|
|
// a silent BadDeviceToken, so a wrong value here is a device that never receives anything and never
|
|
// reports why.
|
|
if (!ENVIRONMENTS.has(environment)) {
|
|
return Response.json({ error: "environment must be 'production' or 'sandbox'" }, { status: 400 });
|
|
}
|
|
|
|
const device = await upsertPushDevice({ userId, token, platform, environment, bundleId, appSlug });
|
|
return Response.json({ ok: true, id: device.id });
|
|
}
|
|
|
|
const match = url.pathname.match(/^\/_officer\/devices\/(.+)$/);
|
|
if (match && req.method === 'DELETE') {
|
|
await deletePushDevice(decodeURIComponent(match[1]!), userId);
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
return null;
|
|
}
|