Files
website/server/src/router/v1/auth/notifications.routes.js
wtclaude 565a7d2c20
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m20s
refactor(server): split public, player and residual auth into capability routers (PR 5)
The last split PR of docs/website/API_V2_PLAN.md § Phase 2. public.routes.js,
player.routes.js and auth.routes.js are deleted; each group is now a directory
whose index.js owns the group gate and the mount table and declares no routes.
Every one of the 200 manifest routes is now in a capability router.

  public/  posts (2) wiki (4) pages (2) shard (12) site (4, group root)
  player/  account (8) shard (8) appeals (4), behind noindex + requireAuth
  auth/    login (2) register (1) invite (2) password (3) session (2, root)

No URL moves. All four gates zero-diff: routes.manifest.json (200 public + 2
internal), routes.guards.json, swagger-output.json (198 operations), and
docs/website/api-route-inventory.json was already in sync. 434 tests green.

Notes on the non-mechanical parts:

- public/index.js and auth/index.js carry no group gate, deliberately, and say
  so. The public surface is anonymous by contract (logged-out SPA, Discord bot,
  Android ShardStreamClient on /public/shard/stream); /auth is where a caller
  becomes authenticated. player/index.js gates on requireAuth only, never
  requireRole('player') — staff are a superset of players.
- GET /auth/me has a mount-order dependency: use('/me', meRouter) matches the
  bare /me, so the request runs meRouter's noindex + requireAuth and falls
  through. session.router.js must stay mounted last. Verified by the
  counterfactual — mounting it first still 401s but drops X-Robots-Tag, which
  no manifest or guards file can see.
- loginGuards moved to auth/loginGuards.js (frozen) rather than being copied
  into the three routers that spread it; sso.routes.js drops its duplicate.
- The :param shadowing check was re-run in dispatch order against the built
  stack: 86 routes, 64 literal, none shadowed. /public/wiki/{categories,tags}
  ahead of /:slug is the only ordering-sensitive pair.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:14 -05:00

101 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Push-notification self-service under /auth/me ──────────────────────────
//
// Device registration + per-user stream subscriptions for the app's opt-in push
// (docs/android/PLAN.md §11). Mounted at /me by auth/index.js alongside
// me.routes.js, behind requireAuth ONLY (role-agnostic — every authenticated
// role manages its own devices/subscriptions), and noindex. The app calls these
// and never touches /admin.
const express = require('express')
const { body, param } = require('express-validator')
const notif = require('./notifications.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const notifRouter = express.Router()
notifRouter.use(noindex, requireAuth)
// ── Devices ────────────────────────────────────────────────────────────────
notifRouter.post(
'/devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Register a push device (endpoint) for the current user'
// #swagger.description = 'Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterDeviceRequest" } } } } */
/* #swagger.responses[201] = { description: 'Device registered', content: { "application/json": { schema: { $ref: "#/components/schemas/PushDevice" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or disallowed endpoint', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('endpoint').isString().trim().isURL({ protocols: ['https'], require_protocol: true }).isLength({ max: 512 }),
body('transport').optional().isIn(['unifiedpush', 'fcm']),
body('platform').optional({ values: 'falsy' }).isString().isLength({ max: 40 }),
validate,
notif.registerDevice,
)
notifRouter.get(
'/devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the current users registered push devices'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Registered devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/PushDevice" } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.listDevices,
)
notifRouter.delete(
'/devices/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Unregister a push device'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Device id (must belong to the caller).' }
/* #swagger.responses[200] = { description: 'Unregistered', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[404] = { description: 'No such device for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
notif.removeDevice,
)
// ── Streams catalog + subscriptions ─────────────────────────────────────────
notifRouter.get(
'/notifications/streams',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List subscribable notification streams (catalog)'
// #swagger.description = 'The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Stream catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationStreams" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getStreams,
)
notifRouter.get(
'/notifications/subscriptions',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Get the current users notification subscriptions'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Subscribed stream ids', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getSubscriptions,
)
notifRouter.put(
'/notifications/subscriptions',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Replace the current users notification subscriptions'
// #swagger.description = 'Sets the full opted-in stream set (applied to all the users devices). Unknown stream ids are ignored; the stored set is echoed back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
/* #swagger.responses[200] = { description: 'Updated subscriptions', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('streams').isArray(),
body('streams.*').isString().isLength({ max: 64 }),
validate,
notif.putSubscriptions,
)
module.exports = notifRouter