openapi: 3.1.0 info: title: Grants UI Backend API version: "0.1.0" description: | OpenAPI specification for the Grants UI Backend service. Notes on authentication: - This API uses Bearer token authentication for service-to-service auth. - Supply the header as: `Authorization: Bearer ` - The token is encrypted using AES-256-GCM and then base64 encoded. contact: name: DEFRA DDTS license: name: OGL-UK-3.0 url: https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/ servers: - url: http://localhost:3001 description: Local - url: https://grants-ui-backend.dev.cdp-int.defra.cloud description: Dev - url: https://grants-ui-backend.test.cdp-int.defra.cloud description: Test tags: - name: Health description: Service health endpoint - name: State description: Manage persisted grant application state x-internal: true - name: Submissions description: Record grant application submissions x-internal: true - name: Allowlist description: Resolve grants a user is permitted to access components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: | Service-to-service authentication. lockToken: type: apiKey in: header name: x-application-lock-owner description: Application lock JWT token. x-internal: true encryptedAuth: type: apiKey in: header name: x-encrypted-auth description: JWT containing crn and sbi claims, used by the allowlist endpoint. schemas: ErrorResponse: type: object properties: error: type: string required: [error] HealthResponse: type: object properties: message: type: string example: success required: [message] StateObject: description: Arbitrary state object stored by the service type: object additionalProperties: true StateDocument: description: >- The full stored state document, including its identifiers, version fields and audit timestamps, with the arbitrary state under `state`. type: object additionalProperties: true properties: _id: type: string description: MongoDB document identifier sbi: type: string grantCode: type: string grantVersion: type: string pinnedMajor: type: integer major: type: integer minor: type: integer patch: type: integer state: $ref: '#/components/schemas/StateObject' createdAt: type: string format: date-time updatedAt: type: string format: date-time required: [sbi, grantCode, grantVersion, state] StateSaveRequest: type: object additionalProperties: false properties: sbi: type: string grantCode: type: string grantVersion: oneOf: - type: string - type: integer default: 1 state: $ref: '#/components/schemas/StateObject' required: [sbi, grantCode, grantVersion, state] StateSaveResponse: type: object properties: success: type: boolean example: true created: type: boolean description: Present and true when a new state record was created updated: type: boolean description: Present and true when an existing state record was updated required: [success] StatePatchResponse: type: object properties: success: type: boolean example: true patched: type: boolean description: Present and true when a state record was patched required: [success] StateDeleteResponse: type: object properties: success: type: boolean example: true deleted: type: boolean example: true required: [success, deleted] FormDefinition: description: A grant form definition document type: object additionalProperties: true properties: grantCode: type: string id: type: string title: type: string major: type: integer minor: type: integer patch: type: integer status: type: string definition: type: object additionalProperties: true StateWithDefinitionRequest: type: object additionalProperties: false properties: sbi: type: string grantCode: type: string includeDefinition: type: boolean default: true description: >- When `true` (the default) the resolved form definition is returned alongside the state. When `false` the caller already holds the form definition locally (e.g. a legacy YAML-sourced form), so the backend skips all definition resolution and version-upgrade work and returns only the stored state (and its version) with no `definition` payload. required: [sbi, grantCode] StateWithDefinitionResponse: type: object additionalProperties: false properties: definition: description: >- The resolved form definition. Present only when the request set `includeDefinition: true` (the default); omitted entirely for state-only reads (`includeDefinition: false`). $ref: '#/components/schemas/FormDefinition' state: description: The application state, or null when no state exists yet oneOf: - type: object additionalProperties: true - type: 'null' upgraded: type: boolean description: >- True when this call persisted a grant version upgrade on the stored state as a side effect; false when the call was read-only or no state existed yet. The application lock is always acquired against the resolved version; on an upgrade the previous version's lock is released best-effort (failures ignored, otherwise reaped by its TTL). fromVersion: type: string description: >- The previous `grantVersion` before the upgrade. Present only when `upgraded` is true. example: '1.0.0' toVersion: type: string description: >- The new `grantVersion` the state was upgraded to. Present only when `upgraded` is true. example: '1.4.2' required: [state, upgraded] Submission: type: object additionalProperties: false properties: crn: type: string sbi: type: string grantCode: type: string grantVersion: oneOf: - type: string - type: integer referenceNumber: type: string previousReferenceNumber: type: ['string', 'null'] submittedAt: type: string format: date-time required: [crn, sbi, grantCode, grantVersion, referenceNumber, submittedAt] SubmissionCreateResponse: type: object properties: success: type: boolean example: true created: type: boolean example: true required: [success, created] SubmissionsRetrieveResponse: type: array description: A list of submissions matching the provided filters items: $ref: '#/components/schemas/Submission' Grant: type: object properties: code: type: string description: Grant code / slug title: type: string description: Human-readable grant title description: type: ['string', 'null'] description: Short description of the grant url: type: ['string', 'null'] description: URL to the grant application, or null if not configured required: [code, title, description, url] AllowlistGrantsResponse: type: object properties: grants: type: array items: $ref: '#/components/schemas/Grant' required: [grants] StatePatchRequest: type: object additionalProperties: false properties: state: type: object additionalProperties: false properties: applicationStatus: type: string required: [applicationStatus] required: [state] security: - bearerAuth: [] paths: /health: get: tags: [Health] summary: Health check description: Returns a simple success message if the service is healthy. security: [] responses: '200': description: Service is healthy content: application/json: schema: $ref: '#/components/schemas/HealthResponse' /state: post: tags: [State] summary: Create or update application state security: - bearerAuth: [] lockToken: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/StateSaveRequest' example: sbi: '1234567890' grantCode: 'example-grant-with-auth' grantVersion: '1.0.0' state: formField1: 'Answer' responses: '201': description: State created content: application/json: schema: $ref: '#/components/schemas/StateSaveResponse' '200': description: State updated content: application/json: schema: $ref: '#/components/schemas/StateSaveResponse' '400': description: Validation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to persist state content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' get: tags: [State] summary: Retrieve application state for an SBI, grant code and version security: - bearerAuth: [] lockToken: [] parameters: - in: query name: sbi schema: type: string required: true - in: query name: grantCode schema: type: string required: true - in: query name: grantVersion schema: oneOf: - type: string - type: integer required: true responses: '200': description: The stored state document content: application/json: schema: $ref: '#/components/schemas/StateDocument' '400': description: Validation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: No state found for the given identifiers content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to retrieve state content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' delete: tags: [State] summary: Delete application state for an SBI, grant code and version security: - bearerAuth: [] lockToken: [] parameters: - in: query name: sbi schema: type: string required: true - in: query name: grantCode schema: type: string required: true - in: query name: grantVersion schema: oneOf: - type: string - type: integer required: true responses: '200': description: State deleted content: application/json: schema: $ref: '#/components/schemas/StateDeleteResponse' '400': description: Validation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: No state found for the given identifiers content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to delete state content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /state/with-definition: post: tags: [State] summary: Retrieve an application's form definition and state together security: - bearerAuth: [] lockToken: [] description: | Returns the resolved form definition and the matching application state in a single call. When no state exists, the latest active definition is returned with `state: null` (the frontend creates the state). When state exists, the latest active definition within the state's pinned major is resolved; if it is newer than the stored version, the state's version fields are upgraded. Unlike the other `/state` routes, this endpoint does NOT enforce the lock via a pre-handler. Because it is partly responsible for resolving the `grantVersion`, a cold first call cannot yet carry a version-bearing token. The lock token is still required (it identifies the owner), but its `grantVersion` claim is optional: the endpoint resolves the authoritative version and then acquires/refreshes the application lock against that resolved version, returning `423` if another owner holds it. When a version upgrade is persisted, the now-orphaned lock on the previous version is released on a best-effort basis (any failure is ignored and that lock simply expires via its TTL). The response always includes an `upgraded` boolean indicating whether a version upgrade was persisted on this call. When `upgraded` is `true`, `fromVersion` and `toVersion` report the previous and new `grantVersion` respectively. Set `includeDefinition: false` in the request body for a state-only read: the caller already holds the form definition locally (e.g. a legacy YAML-sourced form), so the backend skips all definition resolution and version-upgrade work and returns only the stored state (and its version) with no `definition` payload. In that mode the lock is acquired against the state's existing version, and a `404` is never returned (a missing state simply yields `state: null`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/StateWithDefinitionRequest' examples: withDefinition: summary: Definition + state (default) value: sbi: '1234567890' grantCode: 'example-grant-with-auth' stateOnly: summary: State only (caller has the definition locally) value: sbi: '1234567890' grantCode: 'example-grant-with-auth' includeDefinition: false responses: '200': description: >- The resolved form definition and the application state (state may be null). The `upgraded` flag reports whether a grant version upgrade was persisted as a side effect of this call. content: application/json: schema: $ref: '#/components/schemas/StateWithDefinitionResponse' examples: readOnly: summary: No upgrade (read-only) value: definition: grantCode: 'example-grant-with-auth' major: 1 minor: 3 patch: 0 state: grantVersion: '1.3.0' upgraded: false upgraded: summary: Version upgraded as a side effect value: definition: grantCode: 'example-grant-with-auth' major: 1 minor: 4 patch: 2 state: grantVersion: '1.4.2' upgraded: true fromVersion: '1.0.0' toVersion: '1.4.2' stateOnly: summary: State only (includeDefinition false), no definition payload value: state: grantVersion: '1.3.0' upgraded: false '400': description: Validation error or missing lock token claims content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': description: Missing or invalid application lock token content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: >- No form definition found. Only returned when `includeDefinition` is `true`; state-only reads never return `404`. content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '423': description: The application lock is held by another owner content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to retrieve state with form definition content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /state/{sbi}/{grantCode}/{grantVersion}: patch: tags: [State] summary: Patch application status on the state for an SBI, grant code and grant version security: - bearerAuth: [] lockToken: [] parameters: - in: path name: sbi schema: type: string required: true - in: path name: grantCode schema: type: string required: true - in: path name: grantVersion schema: oneOf: - type: string - type: integer required: true requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/StatePatchRequest' example: state: applicationStatus: 'SUBMITTED' responses: '200': description: The updated state record content: application/json: schema: $ref: '#/components/schemas/StatePatchResponse' '400': description: Validation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: No state found for the given identifiers content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to patch state content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /submissions: post: tags: [Submissions] security: - bearerAuth: [] lockToken: [] summary: Record an application submission requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Submission' responses: '201': description: Submission recorded content: application/json: schema: $ref: '#/components/schemas/SubmissionCreateResponse' '400': description: Validation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to record submission content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' get: tags: [Submissions] summary: Retrieve submissions matching the given identifiers security: - bearerAuth: [] parameters: - in: query name: sbi schema: type: string required: true - in: query name: grantCode schema: type: string required: true - in: query name: crn schema: type: string required: false - in: query name: grantVersion schema: oneOf: - type: string - type: integer required: false - in: query name: referenceNumber schema: type: string required: false responses: '200': description: A list of submissions content: application/json: schema: $ref: '#/components/schemas/SubmissionsRetrieveResponse' '400': description: Validation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to retrieve submissions content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /allowlist/grants: get: tags: [Allowlist] summary: Resolve grants accessible to a user description: | Returns the list of active grants the user identified by `crn` and `sbi` is permitted to access. The user identity is read from the `x-encrypted-auth` JWT header (signed with `ENCRYPTED_AUTH_JWT_SECRET`), which must contain `crn` and `sbi` claims. Access rules (evaluated per environment): - Grants with **no allowlist entries** are **closed to all users**. - Grants with `allowAll: true` in their `allowlist.yaml` are **open to all users**. - Otherwise the user must appear in **both** the CRN and SBI lists. The `allowlist.yaml` format (per environment, per grant config): ```yaml dev: allowAll: true # open to everyone in dev test: crns: - '1234567890' sbis: - '123456789' ``` security: - bearerAuth: [] encryptedAuth: [] responses: '200': description: List of grants the user may access content: application/json: schema: $ref: '#/components/schemas/AllowlistGrantsResponse' example: grants: - code: woodland title: Woodland Creation Grant description: Support for creating new woodland areas url: http://localhost:3000/woodland '401': description: Missing or invalid bearer token, or missing crn/sbi in JWT content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Failed to resolve allowlist content: application/json: schema: $ref: '#/components/schemas/ErrorResponse'