openapi: 3.0.3

info:
  title: GIW POC — Identity Platform custom APIs
  version: 0.1.0
  description: |
    Custom (non-standard) APIs of the GIW POC Identity Platform.

    **Scope of this document.** It specifies the APIs Galaxy builds, in two groups:

      1. **Portal API** — what the browser calls. The Wi-Fi Portal renders its own
         UI; there is no Keycloak-rendered login page anywhere in this platform.
      2. **Service APIs** — HR Verification, Entitlement, Session.

    The Portal ↔ Galaxy ID leg is **standard OAuth 2.0 / OIDC** over the token and
    admin endpoints (RFC 6749 §4.3 Resource Owner Password Credentials, RFC 7009)
    and is deliberately *not* re-specified here — see `API-OVERVIEW.md` §2.
    Treating a standard endpoint as a bespoke Galaxy API is how integration teams
    end up with a private fork of OIDC.

    **Authentication is POC-only.** The portal authenticates over Direct Grant
    (`grant_type=password`). RFC 9700 states the password grant MUST NOT be used
    and no exception is claimed here; it is a constrained POC workaround for
    captive-portal UX validation. Production authentication architecture remains
    **TBC** and should prefer browser-based OIDC/federation. The browser /
    Authorization Code + PKCE path is retained in the realm as **PRODUCTION
    CANDIDATE** — see `../docs/AUTH-STRATEGY.md`.

    **Status.** POC. Nothing here is a production contract. Timeouts, TTLs and
    tier names are POC assumptions, not SLAs — see `API-REVIEW-CHECKLIST.md`.

    **Privacy.** `citizenId` (CCCD) appears in exactly one request body in this
    whole specification, and in no response, no log field and no token claim.
    See `DATA-CLASSIFICATION.md`.
  contact:
    name: GIW Architecture
  license:
    name: Internal — Galaxy Holdings, all rights reserved
    url: https://galaxyholdings.vn/

servers:
  - url: "{scheme}://{host}:{portalPort}"
    description: wifi-portal
    variables:
      scheme:
        default: http
        enum: [http, https]
      host:
        default: localhost
        description: Local demo default. Override for any other environment.
      portalPort:
        default: "3000"
  - url: "{scheme}://{host}:{hrPort}"
    description: mock-hr-api
    variables:
      scheme:
        default: http
        enum: [http, https]
      host:
        default: localhost
        description: Local demo default. Override for any other environment.
      hrPort:
        default: "3001"
  - url: "{scheme}://{host}:{entitlementPort}"
    description: entitlement-service
    variables:
      scheme:
        default: http
        enum: [http, https]
      host:
        default: localhost
        description: Local demo default. Override for any other environment.
      entitlementPort:
        default: "3002"
  - url: "{scheme}://{host}:{sessionPort}"
    description: session-service
    variables:
      scheme:
        default: http
        enum: [http, https]
      host:
        default: localhost
        description: Local demo default. Override for any other environment.
      sessionPort:
        default: "3003"
  - url: "{scheme}://{host}:{keycloakPort}"
    description: keycloak — hosts the giw-admin module
    variables:
      scheme:
        default: http
        enum: [http, https]
      host:
        default: localhost
        description: Local demo default. Override for any other environment.
      keycloakPort:
        default: "8080"

tags:
  - name: Portal Auth
    description: |
      Provider: Wi-Fi Portal. Consumer: the portal's own browser frontend.

      These are the only endpoints the browser ever calls. The portal holds the
      tokens; the browser holds an opaque `sid` cookie and nothing else. Each
      successful call returns the whole outcome — identity, entitlement and
      network session — because a captive portal user has one question
      ("am I online?") and should not have to make three round trips to answer it.
  - name: HR Verification
    description: |
      Provider: HR (mocked). Consumer: Keycloak custom authenticator.
      System of record for employment status.
  - name: Entitlement
    description: |
      Provider: Entitlement Service. Consumer: Wi-Fi Portal.
      Turns a verified identity into an access decision. Does not enforce.
  - name: Session
    description: |
      Provider: Session / Network Access Service. Consumer: Wi-Fi Portal.
      Enforces a decision that Entitlement already made. Never decides.
  - name: Member Company Integration
    description: |
      Provider: `giw-admin`, a Keycloak custom module (`RealmResourceProvider`)
      served by Keycloak itself. Consumers: a platform operator through the
      module's own GUI, and each member company's HR system through the webhook.

      **MVP POC scope.** Connector config, batch import, the inbound webhook and
      the decision-table editor sit in one module so the whole integration story
      can be shown in one place. ADR-012 records the target architecture — these
      concerns split into a separate service — and what keeping them here costs
      today: realm attributes have no versioning or review, the webhook secret
      lives beside the config rather than in a vault, and both the event log and
      the duplicate-suppression window are in memory and lost on restart.

      Authentication on `/api/*` is a **realm** bearer token carrying
      `realm-management/manage-users`. A `master`-realm admin token is rejected:
      the module authenticates against the realm it serves. The GUI itself logs
      in with Authorization Code + PKCE — an admin console has none of the
      captive-portal constraints that forced Direct Grant on the portal.
  - name: Operations
    description: Health and test-harness endpoints.

security:
  - {}

paths:

  # ─────────────────────────────────────────────────────── Portal Auth

  /api/v1/auth/login:
    post:
      tags: [Portal Auth]
      operationId: portalLogin
      summary: Sign in an existing Galaxy ID customer
      description: |
        The portal forwards the credentials to Galaxy ID's token endpoint
        (OAuth 2.0 Resource Owner Password Credentials, confidential client), then
        obtains an entitlement and opens a network session before answering.

        **POC ONLY.** Direct Grant is a constrained POC workaround; production
        auth architecture is TBC and should prefer browser OIDC/federation.
        See `../docs/AUTH-STRATEGY.md`.

        **The portal never verifies a credential itself.** It has no password
        store and no HR access; every authentication decision is Keycloak's.

        The password is transited, never persisted and never logged. JavaScript
        strings are immutable so it cannot be zeroed; it goes out of scope when
        the request ends. That is the honest guarantee.

        On success the response sets an opaque, `HttpOnly`, `SameSite=Strict`
        `sid` cookie. Tokens stay server-side.
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginRequest' }
            example: { username: "an.nguyen@example.test", password: "Passw0rd!23" }
      responses:
        '200':
          $ref: '#/components/responses/PortalSession'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          description: |
            Credentials rejected. The body is **identical** for a wrong password,
            an unknown account and a brute-force lockout: distinguishing them
            would be an account-enumeration oracle.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: "INVALID_CREDENTIALS"
                message: "Thông tin đăng nhập không đúng."
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T07:00:00.000Z"
        '403':
          $ref: '#/components/responses/AccessRefused'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'

  /api/v1/auth/register:
    post:
      tags: [Portal Auth]
      operationId: portalRegister
      summary: Create a Galaxy ID and sign straight in
      description: |
        Creates the account through Galaxy ID's Admin API using the portal's own
        service account (scoped to `manage-users`, deliberately not `realm-admin`),
        then performs an ordinary login.

        Register-then-login on purpose: exactly one code path mints a session, so
        there is one place where credentials are checked and one place where an
        entitlement is issued.
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterRequest' }
            example:
              email: "an.nguyen@example.test"
              password: "Passw0rd!23"
              displayName: "Nguyễn Văn An"
      responses:
        '200':
          $ref: '#/components/responses/PortalSession'
        '400':
          description: Email malformed or password shorter than 8 characters
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: An account already exists for this email
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: "ACCOUNT_EXISTS"
                message: "An account with this email already exists"
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T07:00:00.000Z"
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'

  /api/v1/auth/employee:
    post:
      tags: [Portal Auth]
      operationId: portalEmployeeVerify
      summary: Verify an employee by Employee ID + Citizen ID
      description: |
        **POC ONLY.** See `../docs/AUTH-STRATEGY.md`. The production candidate for
        this journey is browser OIDC federating to an Enterprise IdP (ADR-003),
        which would also remove the CCCD.

        The portal forwards both fields to Galaxy ID's token endpoint against a
        client bound to the custom `employee-direct-grant` flow. **Keycloak** then
        calls the HR Verification API and authenticates only on MATCH + ACTIVE.

        The portal is a relay here, not a decision maker: it holds no HR
        credential and cannot verify anyone.

        `citizenId` is sent as its own field, never as the OAuth `password`
        parameter — proxies, APM agents and error reporters treat `password`
        specially, and a CCCD must stay away from that machinery.

        **CCCD handling:** transient. Not stored, not logged, not in any token,
        not echoed in any response. Enforced by automated tests.
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EmployeeVerifyRequest' }
            example: { employeeId: "VJ12345", citizenId: "001234567890" }
      responses:
        '200':
          $ref: '#/components/responses/PortalSession'
        '400':
          description: A field is missing (checked by the portal before any call)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          description: |
            Not verified. **Identical** for an unknown Employee ID, a wrong CCCD
            and a malformed CCCD, so nothing can be probed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: |
            Verified, but refused. `EMPLOYEE_INACTIVE` and `FORBIDDEN` are only
            reachable *after* a correct Employee ID + CCCD, so naming the real
            reason leaks nothing. `ENTITLEMENT_DENIED` means HR said yes and the
            Entitlement Service still said no.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      reason:
                        type: string
                        enum: [COMPANY_NOT_ELIGIBLE, EMPLOYEE_NOT_VERIFIED, UNKNOWN_USER_TYPE]
              examples:
                inactive:
                  value:
                    code: "EMPLOYEE_INACTIVE"
                    message: "Employee record is not active"
                    correlationId: "corr-7f3a"
                    timestamp: "2026-09-21T07:00:00.000Z"
                notEntitled:
                  value:
                    code: "ENTITLEMENT_DENIED"
                    message: "Company PARTNER-X is not in the eligible set"
                    correlationId: "corr-7f3a"
                    timestamp: "2026-09-21T07:00:00.000Z"
                    reason: "COMPANY_NOT_ELIGIBLE"
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
        '504':
          description: HR did not answer within the budget. Access is never granted on timeout.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/auth/session:
    get:
      tags: [Portal Auth]
      operationId: portalSession
      summary: Read the caller's current state
      description: |
        Always `200`. An unauthenticated caller gets `{"authenticated": false}`
        rather than a `401`, because the entry screen asks this on every page load
        and an error status there is noise, not information.

        The network session is re-read from the Session Service so an expiry or a
        revocation elsewhere shows up here.
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Current state, authenticated or not
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PortalSession'
                  - type: object
                    required: [authenticated, correlationId]
                    properties:
                      authenticated: { type: boolean, enum: [false] }
                      correlationId: { type: string }

  /api/v1/auth/refresh:
    post:
      tags: [Portal Auth]
      operationId: portalRefresh
      summary: Renew the access token behind the current session
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          $ref: '#/components/responses/PortalSession'
        '401':
          description: No portal session, or Galaxy ID refused the refresh token
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/auth/logout:
    post:
      tags: [Portal Auth]
      operationId: portalLogout
      summary: End the session and withdraw network access
      description: |
        Revokes the network session, then performs a back-channel logout against
        Galaxy ID, then drops the portal session and clears the cookie.

        Revoke-first is deliberate: if the IdP call fails, access is already off.
        Always `200` — a logout that can fail is a logout users do not trust.
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Session ended
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  correlationId: { type: string }

  /api/v1/meta:
    get:
      tags: [Portal Auth]
      operationId: portalMeta
      summary: Frontend bootstrap data
      servers:
        - url: "{scheme}://{host}:{portalPort}"
          description: wifi-portal
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            portalPort:
              default: "3000"
      responses:
        '200':
          description: Display copy and portal mode
          content:
            application/json:
              schema:
                type: object
                properties:
                  flight: { type: string, example: "VJ178 · SGN→HAN" }
                  idp: { type: string, example: "Galaxy ID (Keycloak)" }
                  mode:
                    type: string
                    enum: [api]
                    description: Always `api`. There is no redirect mode.
                  exposeToken:
                    type: boolean
                    description: Demo aid flag. Must be false outside a laptop.

  # ─────────────────────────────────────────────────── HR Verification

  /api/v1/employees/verify:
    post:
      tags: [HR Verification]
      operationId: verifyEmployee
      summary: Verify an employee by Employee ID + Citizen ID
      description: |
        Confirms that the (employeeId, citizenId) pair matches an employee record
        and reports whether that employee is active and Wi-Fi eligible.

        **Read-only.** No state changes, so a timed-out call is safe to retry.
        The Keycloak authenticator retries once by default.

        **No enumeration.** "No such employee" and "wrong citizenId" return the
        byte-identical body `{"verified":false,"active":false,"reason":"NOT_FOUND_OR_MISMATCH"}`.
        Comparison is constant-time so response latency does not leak a match either.

        **Minimal disclosure.** The response carries a verification verdict and an
        opaque `employeeRef` — never a name, department, salary band or contact detail.
      servers:
        - url: "{scheme}://{host}:{hrPort}"
          description: mock-hr-api
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            hrPort:
              default: "3001"
      security:
        - serviceApiKey: []
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EmployeeVerifyRequest' }
            examples:
              activeEmployee:
                summary: Active Vietjet employee
                value: { employeeId: "VJ12345", citizenId: "001234567890" }
              inactiveEmployee:
                summary: Record exists but is not active
                value: { employeeId: "VJ99999", citizenId: "001234500003" }
      responses:
        '200':
          description: |
            Verification completed. A 200 does **not** mean "authenticated" —
            inspect `verified` and `active`.
          headers:
            X-Correlation-ID: { $ref: '#/components/headers/CorrelationId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmployeeVerifyResponse' }
              examples:
                verifiedActive:
                  summary: Match, active, eligible
                  value:
                    verified: true
                    active: true
                    employeeRef: "EMP-12345"
                    company: "VIETJET"
                    wifiEligible: true
                verifiedInactive:
                  summary: Match, but not an active employee
                  value:
                    verified: true
                    active: false
                    employeeRef: "EMP-99999"
                    company: "VIETJET"
                    wifiEligible: false
                    reason: "EMPLOYEE_INACTIVE"
                notFoundOrMismatch:
                  summary: No match — identical for unknown id and wrong CCCD
                  value: { verified: false, active: false, reason: "NOT_FOUND_OR_MISMATCH" }
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: HR backend unavailable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: "HR_SERVICE_UNAVAILABLE"
                message: "HR system is unavailable"
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T04:30:00.000Z"
        '504':
          description: HR backend timed out
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: "HR_SERVICE_TIMEOUT"
                message: "Upstream HR system timed out"
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T04:30:00.000Z"

  # ──────────────────────────────────────────────────────── Entitlement

  /api/v1/entitlements/evaluate:
    post:
      tags: [Entitlement]
      operationId: evaluateEntitlement
      summary: Decide what access a verified identity is entitled to
      description: |
        Evaluates the caller's **verified access token** against POC policy and
        issues an entitlement.

        **The token is the input, not the body.** `user_type`, `employee_verified`
        and `company` are read from the token after signature, issuer, audience and
        expiry checks. Body fields are treated as hints: a `subjectId` that
        contradicts the token's `sub` is a `403`, and a contradicting `userType` is
        logged and ignored. A portal that could assert its own entitlements would
        make this service decorative.

        **Idempotent.** Repeating a call with the same `Idempotency-Key` (or the
        same subject + session) returns the original entitlement with
        `replayed: true` rather than minting a second one.
      servers:
        - url: "{scheme}://{host}:{entitlementPort}"
          description: entitlement-service
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            entitlementPort:
              default: "3002"
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EntitlementEvaluateRequest' }
            examples:
              employee:
                summary: Employee session
                value:
                  subjectId: "9e115334-e225-4a70-a4b7-e785030ce188"
                  userType: "EMPLOYEE"
                  employeeVerified: true
                  company: "VIETJET"
                  sessionId: "sess-2f1c"
              customer:
                summary: Galaxy ID customer
                value:
                  subjectId: "1a2b3c4d-0000-0000-0000-000000000000"
                  userType: "CUSTOMER"
      responses:
        '201':
          description: Entitlement issued
          headers:
            X-Correlation-ID: { $ref: '#/components/headers/CorrelationId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Entitlement' }
              examples:
                employeePackage:
                  value:
                    entitlementId: "ent-6b1e5b70-1f0a-4a1d-9a9e-2f0f1f7c3a10"
                    subjectId: "9e115334-e225-4a70-a4b7-e785030ce188"
                    sessionId: null
                    eligible: true
                    entitlementType: "WIFI_EMPLOYEE_PACKAGE"
                    status: "ACTIVE"
                    validFrom: "2026-09-21T04:30:00.000Z"
                    validUntil: "2026-09-21T08:30:00.000Z"
                    permission:
                      tier: "BOOST"
                      qosClass: "HIGH"
                      deviceSlots: 2
                      sourceType: "EMPLOYEE_VERIFICATION"
                      sourceRef: "EMP-12345"
                    issuedAt: "2026-09-21T04:30:00.000Z"
        '200':
          description: Replay of a previous decision under the same idempotency key
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Entitlement'
                  - type: object
                    properties:
                      replayed: { type: boolean, example: true }
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Token valid but access refused — either the subject is not entitled
            (`ENTITLEMENT_DENIED`) or the body contradicts the token (`FORBIDDEN`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      eligible: { type: boolean, example: false }
                      reason:
                        type: string
                        enum: [EMPLOYEE_NOT_VERIFIED, COMPANY_NOT_ELIGIBLE, UNKNOWN_USER_TYPE]
              example:
                code: "ENTITLEMENT_DENIED"
                message: "Company PARTNER-X is not in the eligible set"
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T04:30:00.000Z"
                eligible: false
                reason: "COMPANY_NOT_ELIGIBLE"
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/entitlements/{entitlementId}:
    get:
      tags: [Entitlement]
      operationId: getEntitlement
      summary: Read a previously issued entitlement
      servers:
        - url: "{scheme}://{host}:{entitlementPort}"
          description: entitlement-service
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            entitlementPort:
              default: "3002"
      security:
        - bearerAuth: []
      parameters:
        - name: entitlementId
          in: path
          required: true
          schema: { type: string, example: "ent-6b1e5b70-1f0a-4a1d-9a9e-2f0f1f7c3a10" }
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: The entitlement
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Entitlement' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ─────────────────────────────────────────────────────────── Session

  /api/v1/sessions:
    post:
      tags: [Session]
      operationId: createSession
      summary: Open a network session for a subject
      description: |
        Creates a session in state `CREATED`. Creation grants nothing: a session
        without an entitlement carries no access.

        `deviceRef` is an opaque portal-issued handle. **MAC addresses are not
        accepted**: iOS 14+ and Android 15 randomise them per network, so a MAC is
        not a stable device identity and must never anchor metering.
      servers:
        - url: "{scheme}://{host}:{sessionPort}"
          description: session-service
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            sessionPort:
              default: "3003"
      security:
        - serviceApiKey: []
      parameters:
        - $ref: '#/components/parameters/CorrelationId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionCreateRequest' }
            example:
              subjectId: "9e115334-e225-4a70-a4b7-e785030ce188"
              deviceRef: "poc-9e115334"
      responses:
        '201':
          description: Session created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '200':
          description: Replay of an earlier create under the same idempotency key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/sessions/{sessionId}:
    get:
      tags: [Session]
      operationId: getSession
      summary: Read the current state of a session
      servers:
        - url: "{scheme}://{host}:{sessionPort}"
          description: session-service
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            sessionPort:
              default: "3003"
      security:
        - serviceApiKey: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Session state. `GRANTED` past its `expiresAt` reads back as `EXPIRED`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/SessionNotFound'

  /api/v1/sessions/{sessionId}/grant:
    post:
      tags: [Session]
      operationId: grantSession
      summary: Apply an entitlement to a session (open access)
      description: |
        Declarative and idempotent, matching the `applyAccess` verb in ADR-007:
        re-applying the same `entitlementId` is a no-op returning `replayed: true`.

        Refuses with `422` if no `entitlementId` is supplied. This service never
        invents access: every grant traces to an entitlement (ADR-008).
      servers:
        - url: "{scheme}://{host}:{sessionPort}"
          description: session-service
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            sessionPort:
              default: "3003"
      security:
        - serviceApiKey: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionGrantRequest' }
            example:
              entitlementId: "ent-6b1e5b70-1f0a-4a1d-9a9e-2f0f1f7c3a10"
              entitlementType: "WIFI_EMPLOYEE_PACKAGE"
              tier: "BOOST"
              qosClass: "HIGH"
              validUntil: "2026-09-21T08:30:00.000Z"
      responses:
        '200':
          description: Access applied (or already in the desired state)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/SessionNotFound'
        '409':
          description: Session is REVOKED or EXPIRED and cannot be granted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: "SESSION_EXPIRED"
                message: "Session is EXPIRED and cannot be granted"
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T04:30:00.000Z"
        '422':
          description: No entitlementId supplied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: "ENTITLEMENT_DENIED"
                message: "entitlementId is required; access is never granted without an entitlement"
                correlationId: "corr-7f3a"
                timestamp: "2026-09-21T04:30:00.000Z"

  /api/v1/sessions/{sessionId}/revoke:
    post:
      tags: [Session]
      operationId: revokeSession
      summary: Withdraw access from a session
      description: Idempotent (`revokeAccess` in ADR-007). Revoking twice is a no-op.
      servers:
        - url: "{scheme}://{host}:{sessionPort}"
          description: session-service
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            sessionPort:
              default: "3003"
      security:
        - serviceApiKey: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - $ref: '#/components/parameters/CorrelationId'
      responses:
        '200':
          description: Session revoked (or already revoked)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/SessionNotFound'

  # ─────────────────────────────────────────────────────── Operations

  /health:
    get:
      tags: [Operations]
      operationId: health
      summary: Liveness / readiness probe
      description: Present on mock-hr-api, entitlement-service, session-service and wifi-portal.
      responses:
        '200':
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                required: [status, service]
                properties:
                  status: { type: string, enum: [UP], example: UP }
                  service: { type: string, example: entitlement-service }
        '503':
          description: Service is not ready
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/fault:
    post:
      tags: [Operations]
      operationId: setHrFault
      summary: "TEST HARNESS ONLY — inject an HR failure mode"
      description: |
        Forces mock-hr-api to time out or return 503, so the failure cases in
        section 12 of the task order can be exercised end to end.

        **Never ship this endpoint shape.** It exists because a POC that cannot
        demonstrate its own failure modes has not been tested.
      servers:
        - url: "{scheme}://{host}:{hrPort}"
          description: mock-hr-api
          variables:
            scheme:
              default: http
              enum: [http, https]
            host:
              default: localhost
              description: Local demo default. Override for any other environment.
            hrPort:
              default: "3001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mode: { type: string, enum: [none, timeout, unavailable] }
                delayMs: { type: integer, example: 4000 }
      responses:
        '200':
          description: Fault mode applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  fault:
                    type: object
                    properties:
                      mode: { type: string }
                      delayMs: { type: integer }
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '404':
          description: Endpoint absent because the test harness is disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ───────────────────────────────────────── giw-admin module (Keycloak) ──

  /realms/{realm}/giw-admin/api/companies:
    get:
      tags: [Member Company Integration]
      summary: List member company connectors
      description: |
        Returns every configured member company. The webhook secret is **never**
        included — only `hasSecret`, saying whether one is set.
      operationId: listCompanies
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
      responses:
        '200':
          description: The configured companies
          content:
            application/json:
              schema:
                type: object
                properties:
                  companies:
                    type: array
                    items: { $ref: '#/components/schemas/Company' }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }

  /realms/{realm}/giw-admin/api/companies/{code}:
    put:
      tags: [Member Company Integration]
      summary: Create or replace one company's connector settings
      description: |
        Full replace, not a merge. `secret` is write-only: supplying it stores a
        new webhook signing secret, omitting it leaves the current one in place,
        and no response anywhere returns it.
      operationId: putCompany
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - $ref: '#/components/parameters/CompanyCode'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompanyInput' }
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved: { type: boolean, example: true }
                  code:  { type: string, example: VIETJET }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
    delete:
      tags: [Member Company Integration]
      summary: Remove one company's connector settings
      description: |
        Removes configuration and secret. Users already imported from that
        company are **not** touched — deleting a connector is not a way to
        delete people.
      operationId: deleteCompany
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - $ref: '#/components/parameters/CompanyCode'
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  removed: { type: boolean, example: true }
                  code:    { type: string, example: VIETJET }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }

  /realms/{realm}/giw-admin/api/companies/{code}/test:
    post:
      tags: [Member Company Integration]
      summary: Probe the configured HR and Loyalty endpoints
      description: |
        `GET {endpoint-origin}/health` against each configured endpoint, using
        that company's timeout. Read-only; nothing is stored. This is how a
        misconfigured connector is found before an employee hits it at login.
      operationId: testCompany
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - $ref: '#/components/parameters/CompanyCode'
      responses:
        '200':
          description: Probe result per endpoint
          content:
            application/json:
              schema:
                type: object
                properties:
                  hr:      { $ref: '#/components/schemas/Probe' }
                  loyalty: { $ref: '#/components/schemas/Probe' }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
        '404': { $ref: '#/components/responses/AdminNotFound' }

  /realms/{realm}/giw-admin/api/import:
    post:
      tags: [Member Company Integration]
      summary: Batch import users from a member company
      description: |
        Two modes, and the difference matters. `preview` works out what would
        happen and writes nothing; `commit` does it.

        A row is matched in this order: **CCCD link token → `employee_ref` →
        email → create**. Order is not arbitrary — a CCCD identifies a person,
        an email identifies an inbox.

        `citizenId` is converted to `HMAC-SHA256(cccd, realm salt)` on arrival
        and the raw value is dropped. It is not stored, not logged and not
        echoed in the response.

        Import never sets `employee_verified`. A data load is not a
        verification; only a live HR check at login may set that.

        A preview evaluates each row against the realm **as it is now**, not
        against earlier rows in the same file. Two rows sharing a CCCD therefore
        preview as two creates and commit as create + link. The counts differing
        between the two modes is expected.
      operationId: importUsers
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - name: mode
          in: query
          required: false
          description: "`preview` (default) writes nothing; `commit` applies the changes."
          schema: { type: string, enum: [preview, commit], default: preview }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company, rows]
              properties:
                company:
                  type: string
                  example: VIETJET
                rows:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items: { $ref: '#/components/schemas/ImportRow' }
      responses:
        '200':
          description: What happened, or what would happen
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResult' }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
        '413':
          description: More than 5000 rows
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /realms/{realm}/giw-admin/webhook/{company}:
    post:
      tags: [Member Company Integration]
      summary: Receive a change pushed by a member company
      description: |
        Authenticated by HMAC over the **raw body**, not by a bearer token — the
        caller is a member company's HR system, not an operator.

        `X-GIW-Signature` is `base64(HMAC-SHA256(secret, "{timestamp}.{rawBody}"))`.
        Signing the bytes as received is the point: re-serialising the parsed
        object first would verify our own serialiser, not the sender.

        Only events that change **who can log in** are applied. Events about
        **what someone gets** — a loyalty tier, a package purchase — are recorded
        and not applied, because writing a tier into Keycloak would make it a
        cache of the loyalty system, and a stale one.

        **POC limitation.** Duplicate suppression and the event log are in
        memory. An event arriving while Keycloak restarts is lost, and the
        provider will retry a few times and give up — an employee whose
        departure nobody recorded. See ADR-012, **T-WEBHOOK-DURABILITY**.
      operationId: receiveCompanyEvent
      security: [{}]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - name: company
          in: path
          required: true
          schema: { type: string, example: VIETJET }
        - name: X-GIW-Timestamp
          in: header
          required: true
          description: Unix seconds. Rejected outside ±5 minutes of server time.
          schema: { type: string, example: "1790071678" }
        - name: X-GIW-Signature
          in: header
          required: true
          description: '`base64(HMAC-SHA256(secret, "{timestamp}.{rawBody}"))`'
          schema: { type: string }
        - name: X-GIW-Event-Id
          in: header
          required: false
          description: |
            Duplicate suppression. Providers retry; without this, applying an
            event twice is how a reinstated employee ends up disabled again.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompanyEvent' }
      responses:
        '200':
          description: |
            Accepted. `result` says what was done: `APPLIED`, `RECORDED`,
            `DUPLICATE` or `NO_MATCH`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventOutcome' }
        '400':
          description: Body is not valid JSON, or an identity event with no `employeeRef`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventOutcome' }
        '401':
          description: |
            Signature invalid, timestamp outside the window, or no secret
            configured for this company. The three are not distinguished.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventOutcome' }
        '409':
          description: More than one user matches the `employeeRef`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventOutcome' }

  /realms/{realm}/giw-admin/api/events:
    get:
      tags: [Member Company Integration]
      summary: Recent inbound events and what was done with them
      description: |
        A bounded in-memory ring, most recent first. Enough to show an operator
        that events are arriving; **not** a durable event store.
      operationId: listEvents
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: The event log
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: '#/components/schemas/EventLogEntry' }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }

  /realms/{realm}/giw-admin/api/dmn/models:
    get:
      tags: [Member Company Integration]
      summary: Decision models currently loaded
      operationId: listDmnModels
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
      responses:
        '200':
          description: Proxied from dmn-service
          content:
            application/json:
              schema:
                type: object
                properties:
                  models:   { type: array, items: { type: object } }
                  loadedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
        '503': { $ref: '#/components/responses/DmnUnavailable' }

  /realms/{realm}/giw-admin/api/dmn/models/{name}:
    get:
      tags: [Member Company Integration]
      summary: Read one decision model's DMN source
      operationId: getDmnModel
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - $ref: '#/components/parameters/DmnModelName'
      responses:
        '200':
          description: The .dmn file as it is on disk
          content:
            application/json:
              schema:
                type: object
                properties:
                  model: { type: string, example: WifiEntitlement }
                  file:  { type: string, example: wifi-entitlement.dmn }
                  xml:   { type: string }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
        '503': { $ref: '#/components/responses/DmnUnavailable' }
    put:
      tags: [Member Company Integration]
      summary: Replace a decision model and reload it
      description: |
        The old bytes are kept. If the submitted document does not compile they
        are put back and the previous runtime restored **before** the caller is
        told what went wrong — a mistyped FEEL expression must not leave a
        service that denies everyone.

        **No approval workflow.** Anyone holding `manage-users` can change who
        gets access to what, with no version history and no reviewer. This is
        the same gap already recorded for `POST /api/v1/decisions/reload`; see
        `DMN-DECISION-SPEC` §7.
      operationId: putDmnModel
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - $ref: '#/components/parameters/DmnModelName'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [xml]
              properties:
                xml: { type: string, description: The complete DMN 1.3 document }
      responses:
        '200':
          description: Saved and reloaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved:      { type: boolean, example: true }
                  model:      { type: string, example: WifiEntitlement }
                  file:       { type: string, example: wifi-entitlement.dmn }
                  reloadedAt: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
        '422':
          description: |
            Rejected. The previous version is still on disk and still loaded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503': { $ref: '#/components/responses/DmnUnavailable' }

  /realms/{realm}/giw-admin/api/dmn/evaluate/{name}:
    post:
      tags: [Member Company Integration]
      summary: Try a decision without changing anything
      description: |
        For seeing what a rule change would do before saving it. Every input the
        model declares must be supplied; a missing one is a `422`, not a denial.
        A rule set that cannot evaluate must never read as "denied" — that looks
        identical to a legitimate refusal.
      operationId: evaluateDmnModel
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RealmPath'
        - $ref: '#/components/parameters/DmnModelName'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [inputs]
              properties:
                inputs:
                  type: object
                  additionalProperties: true
                  example:
                    userType: CUSTOMER
                    employeeVerified: false
                    company: null
                    memberTier: GOLD
                decision:
                  type: string
                  description: One decision by name. Omit to evaluate all of them.
      responses:
        '200':
          description: Decision output
          content:
            application/json:
              schema:
                type: object
                properties:
                  model:         { type: string }
                  results:       { type: object, additionalProperties: true }
                  correlationId: { type: string }
        '401': { $ref: '#/components/responses/AdminUnauthorized' }
        '403': { $ref: '#/components/responses/AdminForbidden' }
        '422':
          description: The model could not be evaluated — not a denial
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503': { $ref: '#/components/responses/DmnUnavailable' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Keycloak-issued access token. The service verifies signature (RS256 via JWKS),
        `iss`, `aud`/`azp` and `exp` before reading any claim.
    serviceApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Shared service secret. **POC only.** Production target is mTLS or a
        short-lived service token — see `API-SECURITY.md` §4.

  parameters:
    RealmPath:
      name: realm
      in: path
      required: true
      description: Keycloak realm the module is serving.
      schema: { type: string, default: galaxy-id-poc }
    CompanyCode:
      name: code
      in: path
      required: true
      description: Member company code. Uppercase A–Z, digits and hyphen, 2–24 characters.
      schema: { type: string, pattern: '^[A-Z0-9-]{2,24}$', example: VIETJET }
    DmnModelName:
      name: name
      in: path
      required: true
      description: DMN model name as the engine reports it, not the filename.
      schema: { type: string, example: WifiEntitlement }
    CorrelationId:
      name: X-Correlation-ID
      in: header
      required: false
      description: |
        Trace id propagated across Identity → HR → Entitlement → Session. Generated
        if absent and always echoed on the response. Never derived from a CCCD.
      schema: { type: string, example: "corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f" }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Repeat-safe key. The same key returns the original result, not a new one.
      schema: { type: string, example: "9e115334:corr-9b1f" }
    SessionId:
      name: sessionId
      in: path
      required: true
      schema:
        type: string
        pattern: '^[A-Za-z0-9-]{1,64}$'
        example: "sess-2f1c8a90-4b7d-4e01-9c33-8a5f1d2e7b60"

  headers:
    CorrelationId:
      description: Echo of the request correlation id
      schema: { type: string }

  schemas:

    Error:
      type: object
      required: [code, message, correlationId, timestamp]
      properties:
        code:
          type: string
          description: Stable machine-readable code. See ERROR-CATALOG.md.
          enum:
            - INVALID_REQUEST
            - UNAUTHORIZED
            - FORBIDDEN
            - NOT_FOUND
            - EMPLOYEE_VERIFICATION_FAILED
            - EMPLOYEE_INACTIVE
            - HR_SERVICE_TIMEOUT
            - HR_SERVICE_UNAVAILABLE
            - IDENTITY_LINK_CONFLICT
            - INVALID_CREDENTIALS
            - ACCOUNT_EXISTS
            - IDP_UNAVAILABLE
            - IDP_CLIENT_REJECTED
            - SERVICE_UNAVAILABLE
            - ENTITLEMENT_DENIED
            - SESSION_NOT_FOUND
            - SESSION_EXPIRED
            - RATE_LIMITED
            - INTERNAL_ERROR
        message:
          type: string
          description: |
            Human-readable and deliberately vague about *why* an identity check
            failed. Never contains a CCCD, a token, or anything that distinguishes
            "no such user" from "wrong credential".
        correlationId: { type: string }
        timestamp: { type: string, format: date-time }
      example:
        code: "UNAUTHORIZED"
        message: "Service credentials are missing or invalid"
        correlationId: "corr-7f3a"
        timestamp: "2026-09-21T04:30:00.000Z"

    LoginRequest:
      type: object
      required: [username, password]
      properties:
        username: { type: string, format: email, example: "an.nguyen@example.test" }
        password:
          type: string
          format: password
          minLength: 1
          description: |
            **SECRET.** Forwarded once to Galaxy ID and discarded. Never stored,
            logged or echoed by the portal.

    RegisterRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email, example: "an.nguyen@example.test" }
        password:
          type: string
          format: password
          minLength: 8
          description: "**SECRET.** Minimum 8 characters, checked before Galaxy ID is called."
        displayName:
          type: string
          description: Optional. Split on whitespace into firstName / lastName.
          example: "Nguyễn Văn An"

    PortalSession:
      type: object
      required: [authenticated, correlationId, claims]
      description: |
        Everything the frontend needs after a successful authentication, in one
        response: who you are, what you are entitled to, and whether the network
        is actually open.
      properties:
        authenticated: { type: boolean, enum: [true] }
        correlationId: { type: string }
        client:
          type: string
          description: Which Galaxy ID client issued the token.
          enum: [wifi-portal-api, wifi-portal-employee-api]
        claims:
          type: object
          description: |
            Verified token claims, for display and support. See TOKEN-CLAIMS.md.
            Never contains a CCCD.
          additionalProperties: true
        entitlement: { $ref: '#/components/schemas/Entitlement' }
        session: { $ref: '#/components/schemas/Session' }
        rawAccessToken:
          type: string
          description: |
            Present only when `DEBUG_EXPOSE_TOKEN=1`. A demo aid for seeding an
            API client. Must be off outside a laptop.

    EmployeeVerifyRequest:
      type: object
      required: [employeeId, citizenId]
      properties:
        employeeId:
          type: string
          pattern: '^[A-Za-z0-9._-]{3,32}$'
          example: "VJ12345"
        citizenId:
          type: string
          pattern: '^[0-9]{9,12}$'
          example: "001234567890"
          description: |
            **SENSITIVE PERSONAL DATA** (Law 91/2025/QH15). Transient verification
            input only. Must not be stored, logged, cached, echoed, or placed in a
            token by any party. This is the only place it appears in this API.

    EmployeeVerifyResponse:
      type: object
      required: [verified, active]
      properties:
        verified: { type: boolean, description: The id/CCCD pair matched a record }
        active: { type: boolean, description: That record is an active employee }
        employeeRef:
          type: string
          example: "EMP-12345"
          description: Opaque HR reference. Safe to store; not personally identifying on its own.
        company: { type: string, enum: [VIETJET, GALAXY, SOVICO, PARTNER-X], example: "VIETJET" }
        wifiEligible: { type: boolean }
        reason:
          type: string
          enum: [NOT_FOUND_OR_MISMATCH, EMPLOYEE_INACTIVE]

    EntitlementEvaluateRequest:
      type: object
      description: |
        Every field is optional and advisory. Identity comes from the bearer token.
      properties:
        subjectId:
          type: string
          description: Must equal the token `sub` if present, else 403.
        userType: { type: string, enum: [CUSTOMER, EMPLOYEE] }
        employeeVerified: { type: boolean }
        company: { type: string }
        sessionId: { type: string, nullable: true }

    Permission:
      type: object
      description: |
        What the subject may do — deliberately separate from *why* they may do it
        (`sourceType`/`sourceRef`), per ADR-005.
      properties:
        tier: { type: string, enum: [BASIC, BOOST], example: "BOOST" }
        qosClass: { type: string, enum: [STANDARD, HIGH], example: "HIGH" }
        deviceSlots: { type: integer, minimum: 1, example: 2 }
        sourceType:
          type: string
          enum: [EMPLOYEE_VERIFICATION, GALAXY_ID_LOGIN]
        sourceRef: { type: string, nullable: true, example: "EMP-12345" }

    Entitlement:
      type: object
      required: [entitlementId, subjectId, eligible, entitlementType, status, validFrom, validUntil]
      properties:
        entitlementId: { type: string }
        subjectId: { type: string }
        sessionId: { type: string, nullable: true }
        eligible: { type: boolean }
        entitlementType:
          type: string
          enum: [WIFI_EMPLOYEE_PACKAGE, WIFI_CUSTOMER_BASIC]
        status: { type: string, enum: [ACTIVE, DENIED] }
        validFrom: { type: string, format: date-time }
        validUntil: { type: string, format: date-time }
        permission: { $ref: '#/components/schemas/Permission' }
        issuedAt: { type: string, format: date-time }
        replayed: { type: boolean }

    SessionCreateRequest:
      type: object
      required: [subjectId]
      properties:
        subjectId: { type: string }
        deviceRef:
          type: string
          nullable: true
          description: Opaque portal handle. Never a MAC address.

    SessionGrantRequest:
      type: object
      required: [entitlementId]
      properties:
        entitlementId: { type: string }
        entitlementType: { type: string }
        tier: { type: string, enum: [BASIC, BOOST] }
        qosClass: { type: string, enum: [STANDARD, HIGH] }
        validUntil: { type: string, format: date-time }

    Session:
      type: object
      required: [sessionId, subjectId, state, createdAt, updatedAt]
      properties:
        sessionId: { type: string }
        subjectId: { type: string }
        deviceRef: { type: string, nullable: true }
        state: { type: string, enum: [CREATED, GRANTED, REVOKED, EXPIRED] }
        entitlementId: { type: string, nullable: true }
        entitlementType: { type: string, nullable: true }
        tier: { type: string, nullable: true }
        qosClass: { type: string, nullable: true }
        grantedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        replayed: { type: boolean }

    Company:
      type: object
      description: |
        One member company's connector settings. `hasSecret` is the only thing
        ever said about the webhook secret.
      properties:
        code:             { type: string, example: VIETJET }
        name:             { type: string, example: Vietjet Air }
        enabled:          { type: boolean }
        hrEndpoint:       { type: string, example: "http://mock-hr-api:3001/api/v1/employees/verify" }
        loyaltyEndpoint:  { type: string, example: "http://mock-loyalty-api:3006/api/v1/members/lookup" }
        timeoutMs:        { type: integer, example: 3000 }
        employeeIdPrefix: { type: string, example: "VJ" }
        updatedAt:        { type: string, format: date-time }
        updatedBy:        { type: string, example: "giw-operator@giw.test" }
        hasSecret:        { type: boolean, description: Whether a webhook signing secret is set. }

    CompanyInput:
      type: object
      required: [name]
      properties:
        name:             { type: string, example: Vietjet Air }
        enabled:          { type: boolean, default: false }
        hrEndpoint:       { type: string }
        loyaltyEndpoint:  { type: string }
        timeoutMs:        { type: integer, default: 3000, minimum: 500, maximum: 15000 }
        employeeIdPrefix: { type: string }
        secret:
          type: string
          writeOnly: true
          description: |
            Webhook signing secret. Write-only — omit to keep the current one.
            No response in this API returns it.

    Probe:
      type: object
      properties:
        state:
          type: string
          enum: [UP, NOT_CONFIGURED, UNREACHABLE]
          description: Or `HTTP_{status}` when the endpoint answered with something other than 200.
        ms:    { type: integer, description: Round trip in milliseconds. }
        url:   { type: string }
        error: { type: string, description: Exception class name only — never a message. }

    ImportRow:
      type: object
      required: [employeeId]
      properties:
        employeeId: { type: string, example: "12345" }
        citizenId:
          type: string
          writeOnly: true
          description: |
            **Sensitive personal data** (Law 91/2025/QH15). Hashed to a link
            token on arrival; the raw value is not stored, logged or returned.
          example: "001199012345"
        email:      { type: string, format: email, example: "an.nguyen@example.test" }
        fullName:   { type: string, example: "Nguyen Van An" }

    ImportOutcome:
      type: object
      properties:
        line:   { type: integer, description: 1-based row number in the submitted array. }
        action:
          type: string
          enum: [CREATE, LINK_BY_CCCD, LINK_BY_REF, LINK_BY_EMAIL, CONFLICT, INVALID]
        employeeRef:   { type: string, example: "EMP-12345" }
        email:         { type: string }
        username:
          type: string
          description: |
            The login name as the admin console shows it. Where the realm sets
            `registrationEmailAsUsername` this is the email, not the name the
            row was created under.
        matchedUserId: { type: string, format: uuid, nullable: true }
        note:          { type: string }

    ImportResult:
      type: object
      properties:
        mode:    { type: string, enum: [PREVIEW, COMMIT] }
        company: { type: string }
        total:   { type: integer }
        counts:
          type: object
          additionalProperties: { type: integer }
          example: { CREATE: 412, LINK_BY_CCCD: 38, CONFLICT: 6 }
        outcomes:
          type: array
          items: { $ref: '#/components/schemas/ImportOutcome' }

    CompanyEvent:
      type: object
      required: [type]
      properties:
        type:
          type: string
          description: |
            Applied: `EMPLOYEE_TERMINATED`, `EMPLOYEE_SUSPENDED`,
            `EMPLOYEE_REINSTATED`, `EMPLOYEE_COMPANY_CHANGED`.
            Any other type is recorded and not applied.
          example: EMPLOYEE_TERMINATED
        employeeRef:
          type: string
          description: Required for an identity event.
          example: "EMP-12345"
        company:
          type: string
          description: Target company, for `EMPLOYEE_COMPANY_CHANGED`.
          example: GALAXY

    EventOutcome:
      type: object
      properties:
        result:
          type: string
          enum: [APPLIED, RECORDED, DUPLICATE, NO_MATCH, CONFLICT, REJECTED]
        detail: { type: string }

    EventLogEntry:
      type: object
      properties:
        id:      { type: string, description: "The X-GIW-Event-Id as sent, or `-` when the sender omitted it." }
        at:      { type: string, format: date-time }
        company: { type: string }
        type:    { type: string }
        outcome: { type: string }
        detail:  { type: string }
        payload: { type: object, additionalProperties: true }

  responses:
    PortalSession:
      description: |
        Authenticated, entitled and online. Sets an opaque `sid` cookie
        (`HttpOnly`, `SameSite=Strict`, 1 hour).
      headers:
        Set-Cookie:
          description: "sid=<opaque>; Path=/; HttpOnly; SameSite=Strict; Max-Age=3600"
          schema: { type: string }
        X-Correlation-ID: { $ref: '#/components/headers/CorrelationId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/PortalSession' }
          example:
            authenticated: true
            correlationId: "corr-82db1a70-b88d-4543-85b3-dc532b5e9386"
            client: "wifi-portal-employee-api"
            claims:
              sub: "40c49ae4-2d43-4e5d-ad1d-8bc080074f85"
              iss: "http://localhost:8080/realms/galaxy-id-poc"
              aud: ["wifi-portal-employee-api", "account"]
              azp: "wifi-portal-employee-api"
              preferred_username: "emp-12345"
              user_type: "EMPLOYEE"
              employee_verified: true
              employee_ref: "EMP-12345"
              company: "VIETJET"
            entitlement:
              entitlementId: "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30"
              subjectId: "40c49ae4-2d43-4e5d-ad1d-8bc080074f85"
              sessionId: null
              eligible: true
              entitlementType: "WIFI_EMPLOYEE_PACKAGE"
              status: "ACTIVE"
              validFrom: "2026-09-21T07:06:11.076Z"
              validUntil: "2026-09-21T11:06:11.076Z"
              permission:
                tier: "BOOST"
                qosClass: "HIGH"
                deviceSlots: 2
                sourceType: "EMPLOYEE_VERIFICATION"
                sourceRef: "EMP-12345"
            session:
              sessionId: "sess-b8183922-1faf-4f58-899c-4732c20e2ae8"
              subjectId: "40c49ae4-2d43-4e5d-ad1d-8bc080074f85"
              deviceRef: "poc-40c49ae4"
              state: "GRANTED"
              entitlementId: "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30"
              entitlementType: "WIFI_EMPLOYEE_PACKAGE"
              tier: "BOOST"
              qosClass: "HIGH"
              grantedAt: "2026-09-21T07:06:11.090Z"
              expiresAt: "2026-09-21T11:06:11.076Z"
              createdAt: "2026-09-21T07:06:11.085Z"
              updatedAt: "2026-09-21T07:06:11.090Z"

    AccessRefused:
      description: Authenticated, but not entitled
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                properties:
                  reason: { type: string }

    UpstreamUnavailable:
      description: Galaxy ID or a downstream service did not answer
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: "IDP_UNAVAILABLE"
            message: "Galaxy ID did not respond"
            correlationId: "corr-7f3a"
            timestamp: "2026-09-21T07:00:00.000Z"

    InvalidRequest:
      description: Malformed body or a field that fails validation
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: "INVALID_REQUEST"
            message: "employeeId or citizenId has an invalid format"
            correlationId: "corr-7f3a"
            timestamp: "2026-09-21T04:30:00.000Z"
    Unauthorized:
      description: Missing, malformed, expired or untrusted credential
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No such resource
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    SessionNotFound:
      description: No such session
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: "SESSION_NOT_FOUND"
            message: "No such session"
            correlationId: "corr-7f3a"
            timestamp: "2026-09-21T04:30:00.000Z"
    RateLimited:
      description: Too many requests from this caller
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: "RATE_LIMITED"
            message: "Too many verification requests"
            correlationId: "corr-7f3a"
            timestamp: "2026-09-21T04:30:00.000Z"
    InternalError:
      description: Unhandled server error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    AdminUnauthorized:
      description: |
        No valid realm bearer token. A `master`-realm admin token lands here
        too: the module authenticates against the realm it serves.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    AdminForbidden:
      description: |
        The token is valid but lacks `realm-management/manage-users`. A
        signed-in customer reaches exactly this.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    AdminNotFound:
      description: No such member company is configured.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    DmnUnavailable:
      description: |
        The decision service did not answer. Deliberately not reported as a
        denial — an engine outage and a refusal are different things.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
