Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/browser-publishable-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@gatepost/client': minor
'@gatepost/field': minor
---

Accept only publishable keys in a web page, as spec 0.4.0 asks. A browser client now also refuses a key that is neither a publishable key nor a secret key, with its own message. The field refuses such a key in the same way and raises `gatepost-error` with the new code `unsupported_key`.
6 changes: 6 additions & 0 deletions .changeset/core-react-spec-0-4-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@gatepost/core': patch
'@gatepost/react': patch
---

The package implements spec 0.4.0. That version changes only which keys a browser client and the field accept, and leaves the grammar as it was, so every function gives the same results as before.
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ _Avoid_: private key, API secret
An API key that can ship inside an app or a web page. It starts with `nipost_pk_`.
_Avoid_: public key, client key

**Unsupported key**:
A key that is neither a publishable key nor a secret key. A browser client and a field refuse it.
_Avoid_: unknown key, invalid key

**Widget**:
NIPOST's own postcode picker. Gatepost does not build widgets.
_Avoid_: using this word for a Gatepost component
Expand Down
4 changes: 2 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ result.valid; // true

The examples use `FC-01-Z99-ZZ-01`, a synthetic postcode, and show the answers of Gatepost's mock server, which CI runs them against. The real gateway does not know this postcode.

Get a key from NIPOST's dashboard. The gateway refuses every call without a key. A key that starts with `nipost_test_` or `nipost_live_` is a secret key, so keep it on a server. The client refuses a secret key in a page that has a `document`.
Get a key from NIPOST's dashboard. The gateway refuses every call without a key. A key that starts with `nipost_test_` or `nipost_live_` is a secret key, so keep it on a server. In a page that has a `document`, the client accepts only a publishable key, which starts with `nipost_pk_`, and refuses any other key.

Each call raises a `PostcodeError` when it cannot give a result. Its `code` is one of `invalid_input`, `unauthorized`, `insufficient_credits`, `origin_not_allowed`, `forbidden`, `rate_limited`, `server_error`, `unexpected_response`, `network_error` and `timeout`.

Expand Down Expand Up @@ -109,7 +109,7 @@ The client also takes a `transport` option, a function like `fetch`, and each ca
| ------------- | ---------------------------------------------- |
| Node | 22 or later, or a current browser with `fetch` |
| Module format | ESM only |
| Gatepost spec | 0.3.0 |
| Gatepost spec | 0.4.0 |

## Docs

Expand Down
2 changes: 1 addition & 1 deletion packages/client/etc/client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,6 @@ export interface ReverseUnit {
}

// @public
export const SPEC_VERSION = "0.3.0";
export const SPEC_VERSION = "0.4.0";

```
15 changes: 13 additions & 2 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
const PARALLEL_REQUESTS = 4;
const AUTOCOMPLETE_TIMEOUT_MS = 15_000;
const SECRET_KEY = /^nipost_(test|live)_/;
const PUBLISHABLE_KEY = 'nipost_pk_';

// The numeric options and the rule for each. A value that breaks its rule is a programmer error.
const NUMBER_RULES = [
Expand All @@ -43,9 +44,19 @@ function checkOptions(options: ClientOptions): void {
if (apiKey === '') {
throw new RangeError('apiKey must not be empty.');
}
if (apiKey !== undefined && SECRET_KEY.test(apiKey.trim()) && 'document' in globalThis) {
if (apiKey === undefined || !('document' in globalThis)) {
return;
}
// A browser drops white space around a header value, so the check reads the trimmed key.
const key = apiKey.trim();
if (SECRET_KEY.test(key)) {
throw new TypeError('A web page must not hold a secret key. Use a publishable key here.');
}
if (!key.startsWith(PUBLISHABLE_KEY)) {
throw new TypeError(
`The key is not a publishable key. Use a key that starts with ${PUBLISHABLE_KEY} here.`,
);
}
}

/**
Expand Down Expand Up @@ -76,7 +87,7 @@ export class PostcodeClient {
* and the cache time. Each one is optional.
* @throws RangeError for a `timeoutMs` of 0 or less, a `maxRetries` that is not a whole
* number of 0 or more, a `cacheTtlMs` that is negative or not finite, or an empty `apiKey`.
* @throws TypeError for a secret key in a web page.
* @throws TypeError for a key in a web page that is not a publishable key.
*/
constructor(options: ClientOptions = {}) {
checkOptions(options);
Expand Down
4 changes: 2 additions & 2 deletions packages/client/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/client';
*
* SPEC_VERSION; // '0.3.0'
* SPEC_VERSION; // '0.4.0'
* ```
*/
export const SPEC_VERSION = '0.3.0';
export const SPEC_VERSION = '0.4.0';
27 changes: 26 additions & 1 deletion packages/client/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,30 @@ describe('the options', () => {
expect(() => new PostcodeClient({ apiKey: 'nipost_pk_live_abc' })).not.toThrow();
});

it.each(['abc', 'nipost_abc', 'nipost_pk', 'NIPOST_PK_abc', 'x nipost_pk_abc'])(
'refuses an unsupported key in a web page: %j',
(apiKey) => {
vi.stubGlobal('document', {});
expect(() => new PostcodeClient({ apiKey })).toThrow(TypeError);
expect(() => new PostcodeClient({ apiKey })).toThrow(
'The key is not a publishable key. Use a key that starts with nipost_pk_ here.',
);
},
);

it.each([' nipost_pk_abc', '\tnipost_pk_abc\n', '\r\nnipost_pk_abc \t'])(
'allows a publishable key with surrounding white space in a web page: %j',
(apiKey) => {
vi.stubGlobal('document', {});
expect(() => new PostcodeClient({ apiKey })).not.toThrow();
},
);

it('allows no key in a web page', () => {
vi.stubGlobal('document', {});
expect(() => new PostcodeClient()).not.toThrow();
});

it.each([
' nipost_live_abc',
'\tnipost_live_abc',
Expand All @@ -130,8 +154,9 @@ describe('the options', () => {
);
});

it('allows a secret key on a server, where no document exists', () => {
it('allows a secret key and an unsupported key on a server, where no document exists', () => {
expect(() => new PostcodeClient({ apiKey: 'nipost_live_abc' })).not.toThrow();
expect(() => new PostcodeClient({ apiKey: 'abc' })).not.toThrow();
});

it('gives two clients their own keys and their own caches (API-9)', async () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/client/test/key-secrecy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ describe('the API key in an error', () => {
expectNoKey(thrown);
});

it('is not in the error that refuses an unsupported key in a web page', () => {
vi.stubGlobal('document', {});
let thrown: unknown;
try {
new PostcodeClient({ apiKey: SECRET_PART });
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TypeError);
expectNoKey(thrown);
});

it('is not in the error of an option that breaks its rule', () => {
let thrown: unknown;
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ The package checks the form of a postcode. It cannot tell whether a building has
| ------------- | ----------------------------------- |
| Node | 22 or later, or any current browser |
| Module format | ESM only |
| Gatepost spec | 0.3.0 |
| Gatepost spec | 0.4.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.3.0";
export const SPEC_VERSION = "0.4.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.3.0'
* SPEC_VERSION; // '0.4.0'
* ```
*/
export const SPEC_VERSION = '0.3.0';
export const SPEC_VERSION = '0.4.0';
14 changes: 7 additions & 7 deletions packages/field/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ A user who types `fc 01 z99 zz 01` sends `postcode=FC-01-Z99-ZZ-01`. With no key
</form>
```

Use a publishable key, which starts with `nipost_pk_`, and add your site to its allowed origins in NIPOST's dashboard. Anyone can read a key in a page, so the field refuses a secret key: it sends no request, and it logs one console error for you. A key holds a lookup level, and a call above it fails. The field then says that it could not check the postcode. See [`@gatepost/client`](https://github.com/gatepost-dev/js/tree/main/packages/client#readme).
Use a publishable key, which starts with `nipost_pk_`, and add your site to its allowed origins in NIPOST's dashboard. Anyone can read a key in a page, so the field refuses a secret key and any other key that is not a publishable key: it sends no request, and it logs one console error for you. A key holds a lookup level, and a call above it fails. The field then says that it could not check the postcode. See [`@gatepost/client`](https://github.com/gatepost-dev/js/tree/main/packages/client#readme).

A postcode that the gateway does not know, or a failed request, never stops the form. The gateway's data is new, so check the postcode again on your server.

Expand Down Expand Up @@ -96,11 +96,11 @@ A postcode that the gateway does not know, or a failed request, never stops the

The events bubble, so a page can listen on the form. At level 2, `gatepost-confirm` gives the recent house address of the postcode. The field never shows it, and your page should not show it to the person who typed the postcode, who can have typed any postcode.

| Event | `detail` |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| `gatepost-change` | `value`, `postcode`, `source` (`typed`, `pasted`, `suggestion` or `gps`) and `accuracyM` |
| `gatepost-confirm` | `lookup`, the result of the lookup |
| `gatepost-error` | `code`, an error code of `@gatepost/client`, or `secret_key`, `gps_denied` or `gps_unavailable` |
| Event | `detail` |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `gatepost-change` | `value`, `postcode`, `source` (`typed`, `pasted`, `suggestion` or `gps`) and `accuracyM` |
| `gatepost-confirm` | `lookup`, the result of the lookup |
| `gatepost-error` | `code`, an error code of `@gatepost/client`, or `secret_key`, `unsupported_key`, `gps_denied` or `gps_unavailable` |

### Change the words and the look

Expand Down Expand Up @@ -158,7 +158,7 @@ The parts `field`, `label`, `hint`, `input`, `message` and `button` take `::part
| ------------- | ---------------------------------------------------------- |
| Browser | a current Chrome, Edge or Firefox, or Safari 16.4 or later |
| Module format | ESM only |
| Gatepost spec | 0.3.0 |
| Gatepost spec | 0.4.0 |

The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. A server can import the package: it defines the element only in a browser.

Expand Down
6 changes: 3 additions & 3 deletions packages/field/etc/field.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export interface ErrorDetail {
}

// @public
export type FieldErrorCode = PostcodeErrorCode | "secret_key" | "gps_denied" | "gps_unavailable";
export type FieldErrorCode = PostcodeErrorCode | "secret_key" | "unsupported_key" | "gps_denied" | "gps_unavailable";

// @public
export type MessageKey = "label" | "hint" | "empty" | "bad_character" | "bad_length" | "unknown_state" | "bad_lga" | "bad_area" | "bad_unit" | "suggestion" | "use_suggestion" | "legacy_accepted" | "legacy_rejected" | "valid" | "checking" | "not_found" | "confirmed" | "confirmed_place" | "check_failed" | "secret_key" | "use_location" | "locating" | "gps_coarse" | "gps_not_found" | "gps_denied" | "gps_unavailable";
export type MessageKey = "label" | "hint" | "empty" | "bad_character" | "bad_length" | "unknown_state" | "bad_lga" | "bad_area" | "bad_unit" | "suggestion" | "use_suggestion" | "legacy_accepted" | "legacy_rejected" | "valid" | "checking" | "not_found" | "confirmed" | "confirmed_place" | "check_failed" | "secret_key" | "unsupported_key" | "use_location" | "locating" | "gps_coarse" | "gps_not_found" | "gps_denied" | "gps_unavailable";

// @public
export type Messages = Readonly<Record<MessageKey, string>>;
Expand Down Expand Up @@ -67,6 +67,6 @@ export const PostcodeFieldElement: {
};

// @public
export const SPEC_VERSION = "0.3.0";
export const SPEC_VERSION = "0.4.0";

```
2 changes: 1 addition & 1 deletion packages/field/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ for (const [entry, exports] of [
['@gatepost/field', field],
['@gatepost/field/element', element],
]) {
if (exports.SPEC_VERSION !== '0.3.0' || typeof exports.PostcodeFieldElement !== 'function') {
if (exports.SPEC_VERSION !== '0.4.0' || typeof exports.PostcodeFieldElement !== 'function') {
throw new Error(`${entry} gave ${Object.keys(exports).join(', ')} on a server.`);
}
}
31 changes: 19 additions & 12 deletions packages/field/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ class FieldElement extends ElementBase implements PostcodeFieldElement {
#errorsShown = false;
readonly #requests = new Requests();
#client: PostcodeClient | null = null;
#keyRefused = false;
#keyRefused: 'secret_key' | 'unsupported_key' | null = null;
#lastText = '';

/** Builds the shadow tree. The browser calls it when it creates the element. */
Expand Down Expand Up @@ -379,20 +379,25 @@ class FieldElement extends ElementBase implements PostcodeFieldElement {
const apiKey = this.#key;
const baseUrl = this.getAttribute('base-url');
this.#client = null;
this.#keyRefused = false;
this.#keyRefused = null;
if (apiKey === '') {
return;
}
try {
this.#client = new PostcodeClient(baseUrl === null ? { apiKey } : { apiKey, baseUrl });
} catch (error) {
// The client throws a TypeError for a secret key in a web page (SEC-1).
// The client throws a TypeError for each key but a publishable key in a web page (SEC-1).
if (!(error instanceof TypeError)) {
throw error;
}
this.#keyRefused = true;
const secret = /^nipost_(test|live)_/.test(apiKey);
this.#keyRefused = secret ? 'secret_key' : 'unsupported_key';
// eslint-disable-next-line no-console -- spec/field.md asks for one error for the developer.
console.error('gatepost-postcode-field: api-key holds a secret key. Use a publishable key.');
console.error(
secret
? 'gatepost-postcode-field: api-key holds a secret key. Use a publishable key.'
: 'gatepost-postcode-field: api-key is not a publishable key. Use a publishable key.',
);
}
}

Expand Down Expand Up @@ -509,11 +514,12 @@ class FieldElement extends ElementBase implements PostcodeFieldElement {
}

async #lookUp(postcode: Postcode, asked: Asked): Promise<void> {
if (this.#keyRefused) {
this.#requests.outcome = { state: 'error', note: { key: 'secret_key' } };
const refused = this.#keyRefused;
if (refused !== null) {
this.#requests.outcome = { state: 'error', note: { key: refused } };
// The event waits for the render and for the change that caused the lookup.
queueMicrotask(() => {
this.#raise('gatepost-error', { code: 'secret_key' });
this.#raise('gatepost-error', { code: refused });
});
return;
}
Expand Down Expand Up @@ -567,10 +573,11 @@ class FieldElement extends ElementBase implements PostcodeFieldElement {
if (fix.code !== null) {
this.#raise('gatepost-error', { code: fix.code });
}
} else if (this.#keyRefused) {
// The key became a secret key while the field waited for the position.
this.#finish({ state: 'error', note: { key: 'secret_key' } });
this.#raise('gatepost-error', { code: 'secret_key' });
} else if (this.#keyRefused !== null) {
// The key became a refused key while the field waited for the position.
const code = this.#keyRefused;
this.#finish({ state: 'error', note: { key: code } });
this.#raise('gatepost-error', { code });
} else {
// The key is gone: nothing is sent, and the field shows the state that its text gives.
this.#requests.end();
Expand Down
2 changes: 2 additions & 0 deletions packages/field/src/spec-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type MessageKey =
| 'confirmed_place'
| 'check_failed'
| 'secret_key'
| 'unsupported_key'
| 'use_location'
| 'locating'
| 'gps_coarse'
Expand Down Expand Up @@ -65,6 +66,7 @@ export const ENGLISH: Messages = {
confirmed_place: 'We found this postcode: {locality}, {lga}, {state}.',
check_failed: 'We could not check this postcode just now. You can still continue.',
secret_key: 'We cannot check postcodes on this page. You can still continue.',
unsupported_key: 'We cannot check postcodes on this page. You can still continue.',
use_location: 'Use my location',
locating: 'Finding your location.',
gps_coarse: 'We added part of your postcode from your location. Type the rest.',
Expand Down
7 changes: 4 additions & 3 deletions packages/field/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ export interface ConfirmDetail {
}

/**
* Why the field raised `gatepost-error`: an error code of the client, a secret key in the page,
* or a location that the user refused or the device did not give.
* Why the field raised `gatepost-error`: an error code of the client, a secret key or an
* unsupported key in the page, or a location that the user refused or the device did not give.
*/
export type FieldErrorCode = PostcodeErrorCode | 'secret_key' | 'gps_denied' | 'gps_unavailable';
export type FieldErrorCode =
PostcodeErrorCode | 'secret_key' | 'unsupported_key' | 'gps_denied' | 'gps_unavailable';

/**
* The detail of the event `gatepost-error`.
Expand Down
4 changes: 2 additions & 2 deletions packages/field/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/field';
*
* SPEC_VERSION; // '0.3.0'
* SPEC_VERSION; // '0.4.0'
* ```
*/
export const SPEC_VERSION = '0.3.0';
export const SPEC_VERSION = '0.4.0';
17 changes: 17 additions & 0 deletions packages/field/test/location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,23 @@ describe('a location request and the settings', () => {
expect(error).toHaveBeenCalledTimes(1);
});

it('sends nothing and says so when the key is unsupported as the position arrives', async () => {
const place = holdDevice();
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const fetch = vi.spyOn(globalThis, 'fetch');
const { form, field, location, message } = mount(FIELD);
const errors = listen(form);
await userEvent.click(location);
field.setAttribute('api-key', 'pk_abc');
place(5);
await vi.waitFor(() => {
expect(errors).toEqual([{ code: 'unsupported_key' }]);
});
expect(message()).toBe('We cannot check postcodes on this page. You can still continue.');
expect(fetch).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledTimes(1);
});

it('sends nothing and shows the state of the text when the key is gone', async () => {
const place = holdDevice();
const fetch = vi.spyOn(globalThis, 'fetch');
Expand Down
Loading