diff --git a/source/server/middleware/requireAccess.ts b/source/server/middleware/requireAccess.ts new file mode 100644 index 0000000..1f5d1f6 --- /dev/null +++ b/source/server/middleware/requireAccess.ts @@ -0,0 +1,22 @@ +import { Request, Response, NextFunction } from 'express'; + +export type AccessRule = { role: string; product?: string }; + +export function requireAccess( rules: AccessRule[] ) +{ + return ( req: Request, res: Response, next: NextFunction ): void => + { + const auth = req.auth; + if ( !auth ) { res.status( 401 ).json( { error: 'Not authenticated' } ); return; } + + if ( auth.roles.includes( 'superadmin' ) ) { next(); return; } + + const allowed = rules.some( rule => + auth.roles.includes( rule.role ) && + ( !rule.product || auth.products.includes( rule.product ) ) + ); + + if ( allowed ) { next(); return; } + res.status( 403 ).json( { error: 'Forbidden' } ); + }; +} diff --git a/source/server/routes/admin.ts b/source/server/routes/admin.ts index d51b7f1..ae9a910 100644 --- a/source/server/routes/admin.ts +++ b/source/server/routes/admin.ts @@ -21,6 +21,30 @@ router.get( '/users', requireAdmin, ( _req, res ) => res.json( all ); } ); +// PATCH /api/admin/users/:id/products — set products on a user (superadmin only) +router.patch( '/users/:id/products', requireSuperAdmin, ( req, res ) => +{ + const { id } = req.params; + const target = users.findById( id ); + if ( !target ) + { + res.status( 404 ).json( { error: 'User not found' } ); + return; + } + + const { products } = req.body as { products?: string[] }; + if ( !Array.isArray( products ) || !products.every( p => typeof p === 'string' ) ) + { + res.status( 400 ).json( { error: 'products must be an array of strings' } ); + return; + } + + const now = new Date().toISOString(); + const userProducts = products.map( productId => ( { id: productId, acquiredAt: now, source: 'manual' } ) ); + users.update( id, { products: userProducts } ); + res.json( { id, products: userProducts } ); +} ); + // PATCH /api/admin/users/:id/roles — set roles on a user (superadmin only) router.patch( '/users/:id/roles', requireSuperAdmin, ( req, res ) => { diff --git a/source/server/routes/auth.ts b/source/server/routes/auth.ts index 2588c0e..5663621 100644 --- a/source/server/routes/auth.ts +++ b/source/server/routes/auth.ts @@ -251,6 +251,27 @@ router.get( '/reset-token-email', ( req, res ) => res.json( { email: user.email } ); } ); +// POST /api/auth/lookup-email — server-to-server; requires SERVICE_SECRET +router.post( '/lookup-email', ( req, res ) => +{ + const secret = process.env.SERVICE_SECRET; + const auth = req.headers.authorization; + + if ( !secret || auth !== `Bearer ${secret}` ) + { + res.status( 401 ).json( { error: 'Unauthorized' } ); + return; + } + + const { email } = req.body as { email?: string }; + if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; } + + const user = users.findByEmail( email ); + if ( !user ) { res.status( 404 ).json( { error: 'No account found for that email' } ); return; } + + res.json( { id: user.id, email: user.email } ); +} ); + // GET /api/auth/me router.get( '/me', requireAuth, ( req, res ) => { diff --git a/workspace/index.html b/workspace/index.html index 782042e..23a47e5 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -122,12 +122,29 @@

Products

- Each user record has a products array. When a purchase is confirmed - (planned: via a Polar webhook), a product entry is appended. Apps read the product - list from the JWT at runtime — no Polar dependency needed outside the webhook handler. + Each user record has a products array. Products can be assigned + manually via PATCH /api/admin/users/:id/products (superadmin only) + or automatically when a purchase is confirmed (planned: Polar webhook). + Apps read the product list from the JWT at runtime — no round-trip to + rokojori-auth needed.

+
+

Server-to-server lookup

+

+ Other rokojori services can resolve an email address to a user ID without + a user JWT — useful for migrating member records from email-based to + ID-based storage. The caller authenticates with a shared + SERVICE_SECRET environment variable, not a user token. + Returns { id, email } or 404. +

+
+ POST /api/auth/lookup-email + Authorization: Bearer SERVICE_SECRET +
+
+

Rate limiting

@@ -191,6 +208,7 @@ middleware/ requireAuth.ts — JWT verification (cookie or Authorization header) requireAdmin.ts — role guards (requireAdmin, requireSuperAdmin) + requireAccess.ts — flexible role+product access rules (copy into client services) email/ EmailSender.ts — interface SMTPEmailSender.ts @@ -260,6 +278,112 @@

+
+

Per-service access control

+ +
+

Design

+

+ Each rokojori service defines an access rule list — a small array + that declares who is allowed in. The JWT already carries both roles + and products, so no round-trip to rokojori-auth is needed at request time. +

+

+ superadmin is implicitly allowed on every service. No service needs + to include it in its rule list. +

+
+ +
+

AccessRule type

+
type AccessRule = {
+  role: string;     // required — user must have this role
+  product?: string; // optional — user must also have this product
+};
+

+ Multiple rules are OR-combined: access is granted if + any rule matches. Within a single rule, role and + product are AND-combined. +

+
+ +
+

Example — styles.rokojori.com

+
// Grant access to:
+//   • any admin (role alone is sufficient)
+//   • any user who has the "styles" product
+//   • any user who has the "premium" product
+const rules: AccessRule[] = [
+  { role: 'admin' },
+  { role: 'user', product: 'styles' },
+  { role: 'user', product: 'premium' },
+];
+
+ +
+

requireAccess middleware

+
// source/server/middleware/requireAccess.ts
+
+import { Request, Response, NextFunction } from 'express';
+
+type AccessRule = { role: string; product?: string };
+
+export function requireAccess( rules: AccessRule[] )
+{
+  return ( req: Request, res: Response, next: NextFunction ): void =>
+  {
+    const auth = req.auth;
+    if ( !auth ) { res.status( 401 ).json( { error: 'Not authenticated' } ); return; }
+
+    if ( auth.roles.includes( 'superadmin' ) ) { next(); return; }
+
+    const allowed = rules.some( rule =>
+      auth.roles.includes( rule.role ) &&
+      ( !rule.product || auth.products.includes( rule.product ) )
+    );
+
+    if ( allowed ) { next(); return; }
+    res.status( 403 ).json( { error: 'Forbidden' } );
+  };
+}
+

+ Use after requireAuth. For browser pages that should redirect + rather than return JSON, check req.accepts('html') in the 401/403 + branches and redirect to + account.rokojori.com/login?redirect=<current-url> + or back to /. +

+
+ source/server/middleware/requireAccess.ts +
+
+ +
+

Configuration per service

+

+ Each service defines its own rules inline where the router is mounted. + Copy requireAccess.ts into the service's middleware folder — + it has no dependencies beyond the shared AuthPayload type + from requireAuth.ts. +

+
+ +
+

Admin API for product management

+

+ Superadmins can set the full product list on any user via the admin route. + The body is an array of product ID strings; the server records + acquiredAt (now) and source: "manual" for each. + The updated list is reflected in the user's next JWT. +

+
+ PATCH /api/admin/users/:id/products + superadmin only +
+
+ +
+

Planned