Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"ignore": [],
"fixed": [],
"linked": [],
"updateInternalDependencies": "patch"
"updateInternalDependencies": "patch",
"privatePackages": { "version": false, "tag": false }
}
5 changes: 5 additions & 0 deletions .changeset/core-spec-0-2-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gatepost/core': patch
---

The package implements spec 0.2.0. That version adds the client contract and leaves the grammar as it was, so every function gives the same results as before.
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2026 The Gatepost authors
# SPDX-License-Identifier: Apache-2.0
# The image of the mock server needs these paths only. The rest, such as node_modules, stays out
# of the build context.
*
!LICENSE
!NOTICE
!spec/data
!spec/fixtures
!spec/contract
!packages/mock-server/package.json
!packages/mock-server/src
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,29 @@ jobs:
image=ghcr.io/gitleaks/gitleaks # v8.30.1
digest=sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
docker run --rm -v "$PWD:/repo" "$image@$digest" git /repo

# The job builds the image of the mock server and calls it once. It never pushes the image.
# mock-release.yml publishes it.
mock-image:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: true
persist-credentials: false
- run: docker build --file packages/mock-server/Dockerfile --tag postcode-mock:ci .
- name: Start the image and look up the synthetic unit
run: |
# The key comes from the key table that the mock server reads, so no key is in this file.
key="$(jq --raw-output '.keys[] | select(.lookupLevel == 1 and .kind == "secret"
and .lookupScope and .credits and (.rateLimited | not)) | .key' \
spec/fixtures/keys.json | head --lines=1)"
docker run --detach --name mock --publish 4010:4010 postcode-mock:ci
curl --silent --show-error --fail --retry 10 --retry-all-errors --retry-delay 1 \
http://localhost:4010/healthz
curl --silent --show-error --fail-with-body --header "X-API-Key: $key" \
'http://localhost:4010/v1/lookup?code=FC-01-Z99-ZZ-01' | tee "$RUNNER_TEMP/lookup.json"
grep --quiet '"valid":true' "$RUNNER_TEMP/lookup.json"
- if: always()
run: docker logs mock
73 changes: 73 additions & 0 deletions .github/workflows/mock-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: 2026 The Gatepost authors
# SPDX-License-Identifier: Apache-2.0
name: mock release

# Publishes the image of the mock server to the GitHub container registry, with a provenance
# attestation (SEC-7). A pushed image is public, so the workflow starts only for a tag such as
# postcode-mock-v0.1.0, or when a maintainer starts it by hand. No other event starts it.
on:
push:
tags: ['postcode-mock-v*']
workflow_dispatch:
inputs:
version:
description: The version of the image, such as 0.1.0. It must match package.json.
required: true
type: string

permissions:
contents: read

concurrency:
group: mock-release
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
packages: write
id-token: write
attestations: write
env:
IMAGE: ghcr.io/gatepost-dev/postcode-mock
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: true
persist-credentials: false
# The tag or the input gives the version. It must match package.json, so that the image
# and the source in the repo agree.
- name: Check the version
id: version
env:
INPUT_VERSION: ${{ inputs.version }}
REF_NAME: ${{ github.ref_name }}
run: |
version="${INPUT_VERSION:-${REF_NAME#postcode-mock-v}}"
declared="$(jq --raw-output .version packages/mock-server/package.json)"
if [ "$version" != "$declared" ]; then
echo "The version is $version, but package.json says $declared." >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: packages/mock-server/Dockerfile
push: true
tags: ${{ env.IMAGE }}:${{ steps.version.outputs.version }}
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
5 changes: 5 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2026 The Gatepost authors
# SPDX-License-Identifier: Apache-2.0
# This finding is a made-up test sentinel. No gateway issues this key. The test that held it
# in one line now builds it at run time. History cannot change, so the fingerprint stays here.
da87220e01e7b9c94993fc64cfe2e2c7bc2684ef:packages/client/test/key-secrecy.test.ts:generic-api-key:13
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`.
This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core` and the private `@gatepost/mock-server`.

## Read first

Expand Down
14 changes: 13 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,21 @@ _Avoid_: using this word for a Gatepost component
### Gatepost

**Spec**:
The `spec` repo. It holds the grammar, the data, the vectors and the standards. The API fixtures and the completed OpenAPI file come later.
The `spec` repo. It holds the grammar, the client contract, the data, the vectors, the fixtures, the completed OpenAPI file and the standards.
_Avoid_: schema, contract (for the repo)

**Contract scenario**:
One shared test case in `spec/contract/`. It names the calls, the mock server's responses and the outcome that every client must reach.
_Avoid_: contract test (for the file), vector, fixture

**Attempt**:
One request that a client sends for one call. A retry is the second or third attempt.
_Avoid_: try, request (when you count them)

**Evidence**:
How Gatepost knows the shape of a gateway response: `observed` in its own calls, `documented` by NIPOST only, or `assumed` by the mock server.
_Avoid_: source, confidence, proof

**Vector**:
One shared test case in `spec/vectors/` that every SDK must pass.
_Avoid_: fixture, example, golden file
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ TypeScript packages for Nigeria's National Digital Postcode.

> Unofficial. Not made or endorsed by NIPOST.

| Package | What it does |
| --------------------------------- | ------------------------------------------ |
| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. |
| Package | What it does |
| ----------------------------------------------- | --------------------------------------------------- |
| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. |
| [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. |

## Develop

Expand Down
6 changes: 5 additions & 1 deletion commitlint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export default {
rules: {
'header-max-length': [2, 'always', 72],
'scope-empty': [2, 'never'],
'scope-enum': [2, 'always', ['core', 'client', 'field', 'react', 'repo', 'deps', 'release']],
'scope-enum': [
2,
'always',
['core', 'client', 'field', 'react', 'mock', 'repo', 'deps', 'release'],
],
'signed-off-by': [2, 'always', 'Signed-off-by:'],
},
};
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ export default defineConfig(
],
},
},
{
// The mock server is a dev tool that runs on Node, not core code (CS-2), so it can use Node.
files: ['packages/mock-server/src/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'no-restricted-globals': 'off',
'no-restricted-properties': 'off',
},
},
{
files: ['packages/*/test/**/*.ts'],
rules: {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@stryker-mutator/core": "^10.0.0",
"@types/node": "^22.20.4",
"@vitest/coverage-v8": "^5.0.3",
"ajv": "^8.20.0",
"eslint": "^10.11.0",
"eslint-plugin-jsdoc": "^65.0.1",
"fast-check": "^4.10.2",
Expand All @@ -51,6 +52,7 @@
"tsdown": "^0.23.0",
"typescript": "6.0.3",
"typescript-eslint": "^8.71.0",
"vitest": "^5.0.3"
"vitest": "^5.0.3",
"yaml": "^2.9.1"
}
}
6 changes: 3 additions & 3 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ parse(code.padEnd(65)).ok; // false

### Clean typed text

`normalize` cleans text that a user typed or pasted. It applies Unicode NFKC, removes white space, hyphens, dashes, full stops and some zero-width characters, and changes ASCII letters to upper case. It does not check the result. It has no input limit, so call `parse` for text from an untrusted source. The result depends on the Unicode version of the runtime. Spec 0.1.0 names Unicode 17.0, which Node 22.22.2 and later 22.x releases have, and so do Node 24.15.0 and later.
`normalize` cleans text that a user typed or pasted. It applies Unicode NFKC, removes white space, hyphens, dashes, full stops and some zero-width characters, and changes ASCII letters to upper case. It does not check the result. It has no input limit, so call `parse` for text from an untrusted source. The result depends on the Unicode version of the runtime. The spec names Unicode 17.0, which Node 22.22.2 and later 22.x releases have, and so do Node 24.15.0 and later.

```ts
import { normalize } from '@gatepost/core';
Expand Down Expand Up @@ -180,14 +180,14 @@ if (building.ok) {
| ------------- | ----------------------------------- |
| Node | 22 or later, or any current browser |
| Module format | ESM only |
| Gatepost spec | 0.1.0 |
| Gatepost spec | 0.2.0 |

The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements.

```ts
import { SPEC_VERSION } from '@gatepost/core';

SPEC_VERSION; // '0.1.0'
SPEC_VERSION; // '0.2.0'
```

## Docs
Expand Down
2 changes: 1 addition & 1 deletion packages/core/etc/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface Segments {
}

// @public
export const SPEC_VERSION = "0.1.0";
export const SPEC_VERSION = "0.2.0";

// @public
export function stateName(stateCode: string): string | null;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* ```ts
* import { SPEC_VERSION } from '@gatepost/core';
*
* SPEC_VERSION; // '0.1.0'
* SPEC_VERSION; // '0.2.0'
* ```
*/
export const SPEC_VERSION = '0.1.0';
export const SPEC_VERSION = '0.2.0';
25 changes: 25 additions & 0 deletions packages/mock-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2026 The Gatepost authors
# SPDX-License-Identifier: Apache-2.0
# Build from the root of the js repo, with the spec submodule checked out:
# docker build --file packages/mock-server/Dockerfile --tag postcode-mock .
# The mock server has no dependencies and no build. Node runs its TypeScript source.
FROM node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3

ARG DESCRIPTION="Unofficial. Not made or endorsed by NIPOST. A mock of NIPOST's gateway."
LABEL org.opencontainers.image.title="postcode-mock" \
org.opencontainers.image.description="$DESCRIPTION" \
org.opencontainers.image.source="https://github.com/gatepost-dev/js" \
org.opencontainers.image.licenses="Apache-2.0"

WORKDIR /app
COPY LICENSE NOTICE ./
COPY spec/data spec/data
COPY spec/fixtures spec/fixtures
COPY spec/contract spec/contract
COPY packages/mock-server/package.json packages/mock-server/package.json
COPY packages/mock-server/src packages/mock-server/src

ENV HOST=0.0.0.0 PORT=4010
USER node
EXPOSE 4010
CMD ["node", "packages/mock-server/src/main.ts"]
88 changes: 88 additions & 0 deletions packages/mock-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/gatepost-dev/.github/main/brand/gatepost-lockup-dark.svg">
<img src="https://raw.githubusercontent.com/gatepost-dev/.github/main/brand/gatepost-lockup.svg" alt="gatepost" height="48">
</picture>

# @gatepost/mock-server

A local mock of NIPOST's postcode gateway, for the tests of Gatepost's clients.

> Unofficial. Not made or endorsed by NIPOST.

[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml)
[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE)

## Install

The package does not go to npm. Run it from a clone of this repo. It needs no install step, because it has no dependencies and Node runs its TypeScript source.

```sh
git clone --recurse-submodules https://github.com/gatepost-dev/js
node js/packages/mock-server/src/main.ts
```

The command listens on port 4010 and prints its address. `GET /healthz` answers 200 with no key.

A release tag starts a workflow that publishes the image `ghcr.io/gatepost-dev/postcode-mock`. The image listens on port 4010. Until the first release, build the image from the root of a clone:

```sh
docker build --file packages/mock-server/Dockerfile --tag postcode-mock .
docker run --publish 4010:4010 postcode-mock
```

## Quickstart

```ts
import { startMockServer } from '@gatepost/mock-server';

const mock = await startMockServer({ port: 0 });
const response = await fetch(`${mock.url}/v1/lookup?code=FC-01-Z99-ZZ-01`, {
headers: { 'X-API-Key': 'nipost_test_mock_l1' },
});
const body = await response.json();
await mock.close();
```

`body` is `{ data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false } }`, the gateway's answer at lookup level 1.

## What it does

- It answers the six gateway endpoints with the synthetic responses in `spec/fixtures`. `spec/fixtures/README.md` says which request gets which response.
- It treats each key in `spec/fixtures/keys.json` in its own way: a lookup level from 1 to 3, no credits, no lookup scope, a rate limit, or a publishable key for the origin `http://localhost:3000`. A request with no key gets 401 `auth_required`, and any other key gets 401 `invalid_api_key`.
- It counts the requests of each key in each clock minute, and sends the `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers, as the gateway does.
- It does not answer an empty `q` of `autocomplete`, because the gateway does not answer one. The connection stays open until the client gives up.
- A request with the headers `X-Scenario-Id` and `X-Scenario-Run` gets the next response of that contract scenario in `spec/contract`. The CORS headers allow both, so browser tests can send them. The gateway itself does not allow them.

| Variable | Meaning | Default |
| ------------------- | ----------------------------------------------- | ----------------------------------- |
| `PORT` | the port. 0 picks a free one | 4010 |
| `HOST` | the address to listen on | 127.0.0.1, and 0.0.0.0 in the image |
| `MOCK_DELAY_MS` | a wait before every response, for timeout tests | 0 |
| `GATEPOST_SPEC_DIR` | the spec folder | the `spec` submodule |

An empty value counts as no value. At start, the server checks the shape of each file in the spec
folder and names a broken file. It does not check a fixture body against the OpenAPI file. Point
`GATEPOST_SPEC_DIR` only at a spec folder that `make check` has passed.

## Requirements

| Requirement | Version |
| ------------- | ------------------------------------------ |
| Node | 22.22.2 or later 22.x, or 24.15.0 or later |
| Gatepost spec | 0.2.0 |

## Docs

`spec/client.md` defines the behaviour that the contract scenarios test, and `spec/contract/README.md` defines their format.

## Support

Report bugs in GitHub Issues. Report security problems privately, as `SECURITY.md` describes.

## Contributing

Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request.

## Licence

Apache-2.0. See `LICENSE` and `NOTICE`.
Loading