Skip to main content

webscheduler-api-contract

WebScheduler API contracts — response envelopes, versioning, base controller responsibilities, public endpoint guardrails, and the SPA form JSON response contract. Use whenever you're adding, modifying, or reviewing API endpoints, controllers that return JSON, response shapes, error handling, or the JSON returned to SPA-intercepted form submits. Triggers on phrases like "API", "endpoint", "JSON response", "envelope", "BaseApiController", "/api/v1", "SPA form", "redirect after save", "AJAX", or any work in `app/Controllers/Api/`.

소스 정보

저장소
niloc95/xscheduler_ci4
최근 소스 활동
2026년 7월 24일 12:05
감지된 SKILL.md 언어
영어
스타
1
포크
0

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
webscheduler-api-contract
description
WebScheduler API contracts — response envelopes, versioning, base controller responsibilities, public endpoint guardrails, and the SPA form JSON response contract. Use whenever you're adding, modifying, or reviewing API endpoints, controllers that return JSON, response shapes, error handling, or the JSON returned to SPA-intercepted form submits. Triggers on phrases like "API", "endpoint", "JSON response", "envelope", "BaseApiController", "/api/v1", "SPA form", "redirect after save", "AJAX", or any work in `app/Controllers/Api/`.
# WebScheduler — API Contract ## Versioning and Surface - **`/api/v1/…` is the canonical, documented path.** Everything externally callable must be reachable there. - **`/api/…` (unversioned) is an undocumented alias.** The SPA still calls these paths; keep them working, never advertise them. - Both registrations come from the single `$xsApiAuthenticatedRoutes` tuple array in `app/Config/Routes.php`. **Add new authenticated endpoints to that array** — do not hand-register a route in one group only, or the two surfaces drift. - A cross-origin `OPTIONS` matches the `api/(:any)` preflight catch-all route, because CI4 registers routes per-verb and would otherwise 404 during routing before any filter runs. Do not remove it. ## Authentication (Owner Section) Two mechanisms, one identity. See `auth-rbac` for the session/role model itself. | Caller | Mechanism | Filter | | --- | --- | --- | | SPA / same-origin UI | session cookie (`isLoggedIn`) | `api_auth` | | External client | `Authorization: Bearer xsk_<prefix>_<secret>` | `api_auth` | ### Filter usage ```php ['filter' => 'api_auth'] // any authenticated caller ['filter' => 'api_auth:admin,provider'] // plus a role requirement ``` Use `api_auth` — **not** `auth` or `role:` — on any route intended to be externally callable. `auth` and `role:` are session-only and will 401 a valid token. ### Precedence rule (do not regress) **If an `Authorization` header is present it decides the request.** A bad token returns 401 even when the caller also holds a valid session cookie. The filter must never short-circuit on the session before reading the header — that let a browser session silently mask an invalid token. ### Token model - Tokens live in `xs_api_keys`, one row per key, **bound to an `xs_users` row** (`user_id`). - `ApiAuthFilter` loads the bound user and its authoritative `xs_user_roles` and populates `App\Services\ApiIdentity` with **the same user array shape `Auth::login()` writes to the session**. That is why `requireRole()`, provider scoping and `current_business_id()` need no token-specific branches. - Only `key_prefix` (indexed lookup handle) and `password_hash()` of the secret are stored. Plaintext is shown once at creation. - Revocation (`revoked_at`) and expiry (`expires_at`) take effect on the next request. A key bound to a non-`active` user is rejected. - `scopes` is a JSON array; **NULL means "inherit the bound user's role permissions"** and satisfies every `hasScope()` check. There is no shared secret anywhere in the repo. - Rate limit: 120 requests per key per minute → 429 with `Retry-After`. Issue and manage keys with `php spark api:key create|list|revoke` (see `operations`). ### Reading the acting identity In API controllers use `$this->currentUser()` / `requireAuth()` / `requireRole()` — already identity-aware. Anywhere else (services, non-API controllers reachable from an API route) use the `permissions` helper, **never `session()->get('user_id')` directly**: | Instead of | Use | | --- | --- | | `session()->get('user_id')` | `current_user_id()` | | `session()->get('user')` | `current_identity_user()` | | inline `admin > provider > staff` loop | `resolve_active_role($roles, $fallback)` | A direct session read on a token-reachable route silently drops the actor: provider scoping degrades to unscoped, and audit rows get a null `user_id`. ### CORS `allowedOrigins` is empty by default (same-origin only) and is populated from the `api.allowedOrigins` env var. Only an allow-listed origin is echoed back; an unknown origin gets **no** `Access-Control-Allow-Origin` header. **Wildcard origins are not supported** — `/api/*` is CSRF-exempt, so a permissive origin plus cookie auth would be a live CSRF hole. ## Base API Controller Responsibilities `app/Controllers/Api/BaseApiController.php` enforces: - `requireAuth()` - `hasRole()` - `requireRole()` - success / error envelope helpers All API controllers should extend it and use its envelope helpers — do not hand-roll JSON responses. ## Response Envelopes ### Success ```json { "data": {}, "meta": {} } ``` - `data` carries the resource payload - `meta` carries pagination, view metadata, generated_at timestamps, etc. ### Error ```json { "error": { "message": "", "code": "", "details": {} } } ``` - `message` — human-readable - `code` — machine-readable stable identifier - `details` — optional structured info (field errors, etc.) ## Public Endpoint Guardrails Public endpoints (under `/api/v1/public/`) must keep: - Hash/token protections - Rate limiting - Provider/service scoping - **No numeric customer IDs in public URLs** — use `xs_customers.hash` or `xs_appointments.public_token` only See the `public-booking` skill for the full set of public route security rules. ## SPA Form JSON Contract For SPA-intercepted form posts: ```json { "success": true, "message": "Saved", "redirect": "https://app/path", "errors": {} } ``` **Critical rule:** If the success destination equals the current page, **include `redirect` so SPA navigation can force-reload**. Without `redirect`, `spa.js` cannot reliably refresh the current view after a mutation. **Optional error key `helpLink`** (added 2026-07-18): error responses may include `"helpLink": { "url": "...", "label": "...", "field": "email" }`. `spa.js` renders it as an internal link below the named field (or at the top of the form when `field` is omitted/not found) and clears it on the next submit. Used by the user-create duplicate-email 409/422 to offer "Edit the existing user instead". This is the most commonly missed contract — verify it on every controller action that returns to the same page after save. Note: `formSuccess()` (see below) enforces the `redirect` key automatically for any controller using `FormResponseTrait`. ## Form Surface — FormResponseTrait Form controllers (non-API, SPA-intercepted form POSTs) use `App\Traits\FormResponseTrait` instead of hand-rolling `setJSON()` calls: ```php use App\Traits\FormResponseTrait; // Success → HTTP 200, { success: true, message, redirect, ...extra } $this->formSuccess(string $redirect, string $message, array $extra = []) // Error → HTTP $status (default 422), { success: false, message, errors } $this->formError(string $message, array $errors = [], int $status = 422) ``` Applied to: `Services`, `ServiceCategories`, `CustomerManagement`. This is the form-surface equivalent of `BaseApiController` envelope helpers. Do **not** call `$this->response->setStatusCode(...)->setJSON(...)` in any controller that already uses this trait. ## Server-Side View Model APIs (Calendar) The calendar surface uses pre-computed server-side render models via `CalendarController`: | Endpoint | Service | View | | --- | --- | --- | | `GET /api/calendar/day?date=YYYY-MM-DD` | `DayViewService` | Day view | | `GET /api/calendar/week?date=YYYY-MM-DD` | `WeekViewService` | Week view | | `GET /api/calendar/month?year=&month=` | `MonthViewService` | Month view | Common query params: `provider_id`, `service_id`, `location_id`, `status`. Response envelope: `{ "data": { ...viewModel... }, "meta": { "view", "date", "generated_at" } }` See `scheduling` skill for the full calendar data flow. ## Calendar Role Scoping (Automatic) - **Providers:** `CalendarController` scopes to their own appointments only regardless of query params. - **Admins/Staff:** see all appointments; can filter by `provider_id` query param. - Do not rely on `provider_id` query param as the sole authorization mechanism — role-based scoping is enforced server-side. ## Integrations Hub API All under `/api/v1/integrations/`, filter: `api_auth`. Controller: `App\Controllers\Api\V1\Integrations`. | Method | Path | Action | | --- | --- | --- | | `GET` | `/api/v1/integrations` | Returns public integration status for all channels | | `POST` | `/api/v1/integrations/save` | Save credentials for one channel (body: `{ channel, ...fields }`) | | `POST` | `/api/v1/integrations/test` | Test connection for one channel (body: `{ channel }`) | | `POST` | `/api/v1/integrations/disconnect` | Remove integration row for one channel (body: `{ channel }`) | Valid channels: `webhook`, `google_calendar`, `stripe`, `zoom`, `jitsi`, `payfast`. All responses use the standard `{ data, meta }` envelope. Errors use `{ error: { message } }`. The controller delegates entirely to `IntegrationSettingsService` — no business logic in the controller. **Google Calendar OAuth routes** (admin only, not API-versioned): - `GET /oauth/google/authorize` — redirect to Google OAuth consent - `GET /oauth/google/callback` — exchange code, store tokens, redirect to `/settings#integrations` ## Public APIs Inventory - `GET /api/v1/public/services` - `GET /api/v1/public/providers` - `GET /api/v1/public/availability` - `GET /api/v1/providers/slug/{segment}/services` — fetch services by provider slug (used by public booking flow) - `POST` booking submit route See the `public-booking` skill for full public route contract. ## Resource CRUD Surface (external clients) Full CRUD resources, each registered in `$xsApiAuthenticatedRoutes` (Routes.php) so both `/api/v1/…` and the unversioned `/api/…` alias exist behind `api_auth`. Controllers are thin and delegate to existing services — **no business logic in the controller** (see `architecture`). Writes are role-gated via the filter argument (`api_auth:admin,provider` etc.), not hand-rolled in the action. | Resource | Controller | Delegates to | Write roles | | --- | --- | --- | --- | | Services | `Api\V1\Services` | `ServiceMutationService` | admin, provider | | Categories | `Api\V1\Categories` | `ServiceMutationService::*Category` | admin, provider | | Customers | `Api\V1\Customers` | `CustomerService`, `CustomerDeletionService` | admin, provider, staff | | Providers (writes) | `Api\V1\Providers` | `UserManagementMutationService` | create/update: admin, provider · delete(deactivate): admin | | Business hours | `Api\V1\BusinessHours` | `Settings\SettingsApiService` | admin | Conventions: - **camelCase ↔ snake_case bridging** in the controller payload builder (`durationMin`→`duration_min`, `categoryId`→`category_id`, `firstName`→`first_name`). - Uniform service-result arrays (`{success, statusCode, message, errors}` from `UserManagementMutationService` / `CustomerDeletionService`) map to envelopes via a small `respondFrom…()` helper — do not re-implement per action. - Business hours: the **global work window** (`business.work_start/END` settings) only. Per-provider `xs_business_hours` rows are written via the provider `schedule` payload, never here (Rule #2 — those reads stay provider-scoped). - A private controller helper must not be named `validate()` — it collides with `CodeIgniter\Controller::validate()` (must be protected). Use `validatePayload()`. ## OpenAPI Spec + Drift Guard `docs/technical/openapi.yml` (OpenAPI 3.1) is the source of truth for the external surface. Security scheme is `http bearer` with `bearerFormat: opaque` (the `xsk_<prefix>_<secret>` key is **not** a JWT). Reusable `components` mirror the runtime: `SuccessEnvelope`/`ErrorEnvelope`, shared `page`/`length`/`sort`/`q` params, `PaginationMeta`, per-resource schemas, and the error-code enum. `php spark api:spec:validate` (`App\Commands\ApiSpecValidateCommand`) fails when the spec drifts from the route table — **phantom** (spec path with no real `/api/v1` route) or **missing** (a route under a documented prefix absent from the spec). Documented prefixes: appointments, availability, services, providers, customers, categories, business-hours, locations. It runs in CI (build job) and must be updated whenever those routes change. The spec rotted silently once; this guard exists so it can't again. ## Developer Portal (`/developers`) `App\Controllers\DeveloperDocs` serves a public, self-hosted Redoc portal — the contract only, never data: - `GET /developers` — Redoc reference (renders `resources/js/developers.js`, which loads the vendored `resources/redoc/redoc.standalone.js` via Vite `?url`; no CDN — CSP forbids external script hosts). The action adds `blob:` to `child-src` (Redoc's search worker) and **strips the `style-src` nonce** for this response. - `GET /developers/getting-started` — auth/envelope/errors/pagination/rate-limits guide. - `GET /developers/openapi.yaml` — the spec, served same-origin (`connect-src 'self'`). Routes are registered outside the setup/auth groups so the portal renders pre-login. ### Public static docs site (`webscheduler.co.za/developer`) For external developers who don't run the app, the same spec is published as a **standalone static bundle** — no PHP, no CI4, no CDN, zero external requests: - Source templates: `docs-site/index.html`, `docs-site/getting-started.html` (static ports of the in-app views, all relative paths so they work under a `/developer/` subpath). `index.html` loads `./redoc.standalone.js` and inits Redoc against `./openapi.yaml` — no CSP nonce concern (static host). - `npm run docs:build` (`scripts/build-docs-site.js`) assembles `dist/developer/` = the templates + vendored `resources/redoc/redoc.standalone.js` +
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기