Files
api/.aiassistant/rules/Creating and securing routes.md
T
Jeppe Bundgaard 51014bf774 Add development guidelines, testing rules, and secure routes documentation
- Add `.junie/guidelines.md` with comprehensive development instructions.
- Include `.aiassistant/rules/Creating and maintaining tests.md` and `.aiassistant/rules/Creating and securing routes.md`.
- Introduce `CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES` for lane service validation.
- Update `selfserve_lane_relay_controller_t` to enforce service-specific permissions for machine relay.
2026-02-18 14:13:05 +01:00

9.4 KiB
Raw Blame History

apply
apply
always

Creating and securing routes — Rules (Projectspecific)

These rules define how to add HTTP routes to the Copenhagen Truck Wash API and how to secure them consistently. They reflect current patterns in services/nginx/app/routes and the helper APIs in traits\route_t.

  1. Location, naming, and structure

    • Place route classes under services/nginx/app/routes.
    • File names should describe the domain and end with Route.php, e.g., BookingsRoute.php, ModuleSelfServeRoute.php.
    • Namespace must be routes and each file must define one class that uses traits\route_t and implements run(): void.
      <?php
      namespace routes;
      use traits\route_t;
      
      class ExampleRoute
      {
          use route_t;
      
          public function run(): void
          {
              // endpoints go here
          }
      }
      
  2. Registering endpoints (HTTP verbs)

    • Define endpoints inside run() using the helpers provided by route_t:
      • get($path, $handler, $permissions = [])
      • post($path, $handler, $permissions = [])
      • put($path, $handler, $permissions = [])
      • delete($path, $handler, $permissions = [])
      • patch($path, $handler, $permissions = [])
      • options($path, $handler, $permissions = []) (useful for CORS preflight when needed)
    • Handlers are closures that perform validation, authorization, side effects, and write responses via the global $response (classes\response).
    • Example (readonly endpoint):
      $this->get('/example', function () {
          global $response;
          $response->success(['message' => 'Hello World!']);
      });
      
  3. Route paths and local routing

    • Caddy serves the app directly; Traefik adds an external /api prefix for local access.
      • Direct path in the app: /foo/bar.
      • Local access paths (both work):
        • http://localhost/foo/bar (direct)
        • http://localhost/api/foo/bar (Traefik stripprefix /api → still routes to /foo/bar in the app)
    • Keep paths meaningful and resourceoriented. Prefer plural nouns for collections and subpaths for actions when unavoidable (e.g., /modules/self-serve/lane/status).
  4. Responses and status codes

    • Use $response->success($payload, $status = 200) for successful results and $response->error($messageOrPayload, $status) for failures.
    • Typical statuses:
      • 200 OK for successful reads/updates; 201 Created when new resources are created.
      • 400 Bad Request for validation errors.
      • 401 Unauthorized when authentication is missing/invalid.
      • 403 Forbidden when authenticated but lacking permissions.
    • Return concise, nonsensitive error messages. Internal details go to logs (see objects\logs_o).
  5. Input handling and validation (route_t helpers)

    • Read parameters via:
      • getParameter($name) or getParametersAsArray() (preferred; integrates with classes\response parsing)
      • fromRequest($name) (checks JSON body, then $_POST, then query)
      • fromQuery($name) and fromRoute($segmentName) when relevant
    • Validate early and fail fast using helpers:
      • Presence: requireParameters(['a','b']), isParametersSet(['a','b'])
      • Types: requireType($val, type_string()|type_int()|TYPE_BOOL()|TYPE_ARRAY()), requireTypeIn($val, [...])
      • Ranges/lengths: requireMinValue($n, $min), requireMaxValue($n, $max), requireMaxLength($name, $len), requireParameterIntPositive($n, $name)
      • Formats/enums: requireDateFormat($date, FORMAT_DATE()), requireInArray($val, $allowed)
  6. Authentication and authorization (core rules)

    • Default posture: endpoints are protected and require authentication unless explicitly public.
      • To check authentication without a specific permission: isAuthenticated().
    • Enforce permissions inside handlers using:
      • requirePermission('some_permission') — throws 401/403 as appropriate.
      • hasPermission('some_permission', $customerNumber = null) — boolean check (do not throw).
    • Register permissions for discoverability/administration by supplying the third $permissions argument when declaring the route. Keys are permission names; values are humanreadable descriptions. You should still call requirePermission() inside the handler to actually enforce.
      $this->get('/modules/self-serve/lane/status', function () {
          global $response;
          self::requirePermission('modules_selfserve_lane_status_view');
          // ...
          $response->success(['status' => 'OK']);
      }, [
          'modules_selfserve_lane_status_view' => 'View self-serve lane status',
      ]);
      
  7. Subusers, permission nodes, and customer context

    • When a permission corresponds to a subuser permission node, define it with definePermission($perm, subusers_permission_node_key::CASE) to keep mapping explicit.
    • Subuser contexts may target a specific customer via the X-Customer-Number header or a customer_number parameter; route_t exposes this as response meta (target_customer_number) when applicable.
    • Use allowOwnOrDepartmentAccess($ownPerm, $deptPerm, $targetCustomerNumber, $departmentId, $ownGuard = null, $denyMessage = null) for the common pattern: allow the principal to act on their own customer scope or fall back to a department/admin permission.
    • Use isOwnCustomerContext($targetCustomerNumber) and resolveEffectiveCustomerNumber() when building conditional logic.
  8. Department, order, and special access helpers

    • Department access: requireDepartmentAccess($departmentId, $permissionSuffix = null), hasDepartmentAccess(...).
    • Order access: requireOrderAccess($orderId, $permissionSuffix = null) (note: indirect access rules may evolve; keep logic minimal in routes).
    • Plate scanners (API key based): protect endpoints with requirePlateScannerAuth().
    • Bot protection (public forms): use requireRecaptcha() and expect g_recaptcha_response in the request.
  9. Public endpoints

    • Keep public endpoints extremely limited. If an endpoint must be public, you must:
      • Validate all inputs thoroughly using the helpers above.
      • Add ratelimit or abusemitigation where relevant (Redis is available as classes\redis for counters/locks; coordinate design with maintainers before introducing new limits).
      • Prefer requireRecaptcha() for anonymous form submissions.
  10. Side effects and idempotency

  • For POST/PUT/PATCH with side effects, make operations idempotent when feasible (e.g., by honoring a client idempotency key header). If you add such behavior, document it in the routes docblock and in openapi.yaml.
  • Wrap multistep operations with proper validation and permission checks before any external calls (Stripe, economic, etc.). Do not leak thirdparty error payloads directly; map to concise API errors and log details.
  1. Logging and observability
  • On auth/permission denials, the helpers already log to logs_o with structured messages. Avoid duplicating logs for the same event.
  • For unexpected states you handle gracefully, add targeted logs via objects\logs_o with a clear category/key.
  1. OpenAPI contract synchronization
  • Every public surface change must be reflected in openapi.yaml at repo root. Keep paths, methods, parameters, request/response schemas, and error statuses up to date.
  • Use the same path strings as in the route declaration (remember Traefiks /api is stripped before hitting the app).
  • If you add or change permissions that users must hold, document them in the endpoint description.
  1. Example: secure command endpoint
<?php
namespace routes;
use traits\route_t;
use classes\authentication;

class LaneCommandRoute
{
    use route_t;

    public function run(): void
    {
        $this->post('/modules/self-serve/lane/command', function () {
            global $response;

            // AuthZ
            self::requirePermission('modules_selfserve_lane_command_execute');

            // Validate input
            self::requireParameters(['lane_id', 'command']);
            $laneId = (int) self::getParameter('lane_id');
            self::requireType($laneId, self::type_int());
            self::requireMinValue($laneId, 1);

            $cmd = (string) self::getParameter('command');
            self::requireType($cmd, self::type_string());
            self::requireInArray($cmd, ['START','STOP','RESERVE','RELEASE','RESET']);

            // Business logic (call into modules/classes)
            // ...

            $response->success(['ok' => true]);
        }, [
            'modules_selfserve_lane_command_execute' => 'Execute selfserve lane commands',
        ]);
    }
}
  1. Quick reference (local)
  • Bring up minimal stack: docker compose up -d traefik redis php1 caddy
  • Access an app route locally:
    • http://localhost/<path> or http://localhost/api/<path>
  • Tests are CLI scripts under services/nginx/app/tests. Prefer unitstyle tests for core logic; see “Creating and maintaining tests — Rules”.
  1. Code style and hygiene
  • Follow .editorconfig (UTF8, CRLF, 4space indents).
  • Keep route files cohesive; avoid adding unrelated endpoints to the same class. If a class exceeds ~200300 lines or mixes domains, split it.
  • Do not perform heavy bootstrap in route files. Delegate to classes/modules and keep handlers thin: validate → authorize → invoke → respond.