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 @@
- 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.
+ 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.
+
@@ -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 @@
+ 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.
+
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.
+
// 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' },
+];
+ // 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 /.
+
+ 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.
+
+ 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.
+