diff --git a/CONTEXT.md b/CONTEXT.md index 4b82f5f..1340405 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/VERSION b/VERSION index 0d91a54..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 diff --git a/client.md b/client.md index 4d47ed6..76bcb0d 100644 --- a/client.md +++ b/client.md @@ -111,6 +111,17 @@ A client ignores a field that this contract does not use, in a lookup, a reverse - The client writes each number in the query in its shortest decimal form, with no exponent and no trailing zero: `lat=9`, not `lat=9.0` and not `lat=9e0`. A negative zero is written `0`. A small value is written in full: `0.0000001`, not `1e-7`. Languages print numbers in different forms, so one fixed form gives every client the same query for the same input. The mock server compares numbers by value, so it also accepts `9.0`. The scenarios `reverse-plain-decimal` and `reverse-whole-number` test this rule. - `autocomplete` normalises its text with the core. Empty text, text of more than 11 characters, and text with a character other than A to Z and 0 to 9 raise `invalid_input`, and the client sends no request. The gateway does not answer an empty `q`. The request sends the normalised text: `q=FC01Z`. The scenarios `autocomplete-empty-input`, `autocomplete-too-long`, `autocomplete-bad-character` and `autocomplete-district` test this rule. +## Keys in a browser + +A browser client runs in a web page, where `document` exists. Anyone can read a key that a web page holds, so a browser client accepts only a publishable key (SEC-1). A server client accepts any key, a secret key included. + +- With no key, a browser client sends no `X-API-Key` header, as a server client does. +- A browser client reads a key without the white space at the start and the end. A browser drops that white space from a header value, so a padded key reaches the gateway as the key itself. +- A key that starts with `nipost_pk_` passes. This checks the kind of the key only. The gateway can still refuse it. +- For a key that starts with `nipost_test_` or `nipost_live_`, `PostcodeClient(options)` raises a type error, `TypeError` in TypeScript. Its message says that a web page must not hold a secret key. +- For an unsupported key, which is any other key, it raises the same type error with a second message. That message says that the key is not a publishable key. +- Neither message holds the key (ERR-3). No client is created with a refused key, so no request leaves with it. + ## Errors `PostcodeError` has these fields: @@ -170,6 +181,7 @@ Each client tests these rules itself. - The programmer errors in the client options. - Cancellation (API-11), because each language cancels in its own way. - That no error message holds the API key. +- The keys in a browser. A scenario runs in no web page, so each client tests a publishable key, no key, both secret prefixes, an unsupported key and a key with white space around it. - The cancellation rules of sharing and the queue: a shared request stops only when every caller cancels, and a cancelled call frees its place among the 4. - The default `timeoutMs` of 15 seconds for `autocomplete`, because a scenario would wait that long. - The random part of each wait, beyond its bounds. diff --git a/field.md b/field.md index 0911213..c63ae4c 100644 --- a/field.md +++ b/field.md @@ -27,7 +27,7 @@ A field exposes the symbols in this table and the types that they need. A langua |---|---| | `change` | `value`: the form value. `postcode`: the postcode when the text parses as a whole postcode, or null. `source`: `typed`, `pasted`, `suggestion` or `gps`. `accuracyM`: the accuracy of the location in metres when `source` is `gps`, or null | | `confirm` | `lookup`: the lookup result of the client | -| `error` | `code`: an error code of the client, or `secret_key`, `gps_denied` or `gps_unavailable`. The table in Failures gives the code of each failure | +| `error` | `code`: an error code of the client, or `secret_key`, `unsupported_key`, `gps_denied` or `gps_unavailable`. The table in Failures gives the code of each failure | ### On the web @@ -80,9 +80,9 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - A change of `baseUrl` alone starts no lookup and cancels none. The next request uses the new address. - A press of the location button cancels the lookup in progress, and the field forgets the remembered lookup. - The field ignores the answer to a cancelled request. It changes nothing on screen, and it raises no event. -- The field refuses a secret key, which starts with `nipost_test_` or `nipost_live_`. It sends no request with that key, and it shows no location button. Location says what happens to a location request in progress when the key changes. -- The field logs one console error for the developer each time it reads a secret key. It reads the key when it starts, and when the key or `baseUrl` changes. The error never holds the key (ERR-3). -- In place of each lookup that it would start, the field shows the state `error` and raises the event `error` with the code `secret_key`. So an app that adds its listener late still hears of it. With `confirm` set to `none`, the field raises no event. +- The field accepts only a publishable key, which starts with `nipost_pk_`. It reads the key without the white space at the start and the end. It refuses a secret key, which starts with `nipost_test_` or `nipost_live_`, and an unsupported key, which is any other key. It sends no request with a refused key, and it shows no location button. Location says what happens to a location request in progress when the key changes. +- The field logs one console error for the developer each time it reads a key that it refuses. It reads the key when it starts, and when the key or `baseUrl` changes. The error never holds the key (ERR-3). +- In place of each lookup that it would start, the field shows the state `error` and raises the event `error`. The code is `secret_key` for a secret key and `unsupported_key` for an unsupported key. So an app that adds its listener late still hears of it. With `confirm` set to `none`, the field raises no event. - At level 2, the confirmation names the locality, the LGA and the state from the administrative address, when the lookup gives all three. Otherwise, it names the state of the postcode with `stateName`. - The event `confirm` gives the app the whole lookup result. Privacy says what the field itself never shows. @@ -103,7 +103,7 @@ The web field is the custom element `gatepost-postcode-field`. It is a form-asso - The user can always type. A change of the text cancels a location request in progress. - A new press of the button and the removal of the field also cancel it. A change of a setting never cancels it, not even a change of the key. - A request that the field sends after a change of the key follows the rules for the new key. When the position arrives, the field sends `reverse` with the key that it holds then. -- With a secret key, it sends nothing, shows the state `error` and raises the event `error` with the code `secret_key`. With no key, it sends nothing and shows the state that its text gives. +- With a refused key, it sends nothing, shows the state `error` and raises the event `error` with the code that Lookups gives. With no key, it sends nothing and shows the state that its text gives. - A `reverse` call that the field sent before the change stays, and its answer counts. - While a location request is in progress, the field starts no lookup. A change of a setting then changes only the validity. - The states `GPS coarse` and `GPS denied` last until the text changes. @@ -123,6 +123,7 @@ Each failure gives one state and one message. The last column gives the code of | a lookup fails with an error of the client | error | `check_failed` | the code of the client's error, such as `network_error` or `rate_limited` | | a lookup throws an error that is not an error of the client, such as for a base address with no scheme | error | `check_failed` | `network_error` | | a lookup is due, and the key is a secret key | error | `secret_key` | `secret_key` | +| a lookup is due, and the key is an unsupported key | error | `unsupported_key` | `unsupported_key` | | the user or the platform refuses the location | GPS denied | `gps_denied` | `gps_denied` | | the platform has no location API | GPS denied | `gps_unavailable` | `gps_unavailable` | | the device reports that it has no location | GPS denied | `gps_unavailable` | `gps_unavailable` | @@ -131,6 +132,7 @@ Each failure gives one state and one message. The last column gives the code of | `reverse` throws an error that is not an error of the client | GPS denied | `gps_unavailable` | `network_error` | | `reverse` gives no postcode that parses | GPS denied | `gps_not_found` | no event | | `reverse` is due, and the key is now a secret key | error | `secret_key` | `secret_key` | +| `reverse` is due, and the key is now an unsupported key | error | `unsupported_key` | `unsupported_key` | ## States @@ -142,7 +144,7 @@ The field shows the first state in this table whose condition holds. The first s | checking | a lookup is in progress | `checking` | | confirmed | the remembered lookup found the postcode | `confirmed` or `confirmed_place` | | not found | the remembered lookup says that the gateway does not know the postcode | `not_found` | -| error | the last lookup failed. Or a lookup or a `reverse` call was due, and the key is a secret key | `check_failed` or `secret_key` | +| error | the last lookup failed. Or a lookup or a `reverse` call was due, and the key is a secret key or an unsupported key | `check_failed`, `secret_key` or `unsupported_key` | | GPS coarse | the last location request gave a partial postcode, and the text has not changed since | `gps_coarse` | | GPS denied | the last location request failed, and the text has not changed since | `gps_denied`, `gps_unavailable` or `gps_not_found` | | legacy code | the text is a legacy postcode | `legacy_accepted` or `legacy_rejected` | @@ -179,6 +181,7 @@ The field shows the first state in this table whose condition holds. The first s | `confirmed_place` | `{locality}`, `{lga}`, `{state}` | in the state `confirmed`, when the lookup gives the locality, the LGA and the state names | | `check_failed` | none | in the state `error`, after a failed lookup | | `secret_key` | none | in the state `error`, for a secret key | +| `unsupported_key` | none | in the state `error`, for an unsupported key. A user does not need to know the kind of key, so the English text is that of `secret_key` | | `use_location` | none | as the text of the location button | | `locating` | none | in the state `GPS locating` | | `gps_coarse` | none | in the state `GPS coarse`. A precise fix with no unit also gives a partial postcode, so the text names no cause and no count of segments | diff --git a/grammar.md b/grammar.md index 57b2d68..232284a 100644 --- a/grammar.md +++ b/grammar.md @@ -1,6 +1,6 @@ # Postcode grammar -Spec version 0.3.0. This file defines how every Gatepost SDK reads and writes Nigeria's digital postcodes. The values live in `data/`. The vectors in `vectors/` test every rule, except the rules in the list below. If this file and a vector disagree, this file wins. Such a disagreement is a bug in the spec. +Spec version 0.4.0. This file defines how every Gatepost SDK reads and writes Nigeria's digital postcodes. The values live in `data/`. The vectors in `vectors/` test every rule, except the rules in the list below. If this file and a vector disagree, this file wins. Such a disagreement is a bug in the spec. No shared vector tests these rules. Each SDK tests them itself. diff --git a/messages/field_en.arb b/messages/field_en.arb index 936acc7..768b128 100644 --- a/messages/field_en.arb +++ b/messages/field_en.arb @@ -111,6 +111,10 @@ "@secret_key": { "description": "The page holds a secret key, so the field sends no request." }, + "unsupported_key": "We cannot check postcodes on this page. You can still continue.", + "@unsupported_key": { + "description": "The page holds a key of an unknown kind, so the field sends no request." + }, "use_location": "Use my location", "@use_location": { "description": "The button that finds the postcode of the user's location." diff --git a/scripts/tests/test_client_doc.py b/scripts/tests/test_client_doc.py index f518a3d..da68eae 100644 --- a/scripts/tests/test_client_doc.py +++ b/scripts/tests/test_client_doc.py @@ -106,6 +106,32 @@ def test_a_number_is_written_in_its_shortest_decimal_form(self) -> None: self.assertIn(text, bullet) +class BrowserKeyTest(unittest.TestCase): + keys = section(CLIENT, "## Keys in a browser") + + def test_a_browser_client_accepts_only_a_publishable_key(self) -> None: + for text in ( + "a browser client accepts only a publishable key (SEC-1)", + "A server client accepts any key, a secret key included.", + "without the white space at the start and the end", + "A key that starts with `nipost_pk_` passes.", + "Neither message holds the key (ERR-3).", + ): + with self.subTest(text=text): + self.assertIn(text, self.keys) + + def test_a_secret_key_and_an_unsupported_key_get_two_messages(self) -> None: + lines = self.keys.splitlines() + secret = next(x for x in lines if "`nipost_test_` or `nipost_live_`" in x) + other = next(x for x in lines if x.startswith("- For an unsupported key")) + self.assertIn("must not hold a secret key", secret) + self.assertIn("is not a publishable key", other) + + def test_each_client_tests_the_keys_in_a_browser(self) -> None: + untested = section(CLIENT, "## Rules that no scenario tests") + self.assertIn("The keys in a browser.", untested) + + class UnknownFieldsTest(unittest.TestCase): def test_a_field_that_the_contract_does_not_use_never_changes_a_result(self) -> None: paragraph = next(x for x in CLIENT.split("\n\n") if x.startswith("A client ignores")) @@ -211,6 +237,11 @@ def test_the_ranking_line_and_api_1_send_the_reader_to_client_md(self) -> None: self.assertIn("`spec/client.md` wins over a scenario", ranking) self.assertIn("`spec/client.md`", api_1) + def test_sec_1_accepts_only_publishable_keys(self) -> None: + lines = STANDARDS.read_text(encoding="utf-8").splitlines() + sec_1 = next(x for x in lines if x.startswith("- **SEC-1")) + self.assertIn("accepts only publishable keys, which start with `nipost_pk_`", sec_1) + def test_the_mock_server_section_does_not_list_the_core_only_rule(self) -> None: text = TYPESCRIPT.read_text(encoding="utf-8") mock = section(text, "## The mock server") diff --git a/scripts/tests/test_field_doc.py b/scripts/tests/test_field_doc.py index 9938490..421e396 100644 --- a/scripts/tests/test_field_doc.py +++ b/scripts/tests/test_field_doc.py @@ -16,7 +16,7 @@ TYPESCRIPT = ROOT / "standards" / "languages" / "typescript.md" PLACEHOLDER = re.compile(r"\{(\w+)\}") CODE = re.compile(r"`([a-z_]+)`") -FIELD_CODES = {"secret_key", "gps_denied", "gps_unavailable"} +FIELD_CODES = {"secret_key", "unsupported_key", "gps_denied", "gps_unavailable"} STATES = [ "GPS locating", "checking", @@ -173,6 +173,7 @@ def test_each_failure_gives_a_state_a_message_and_an_event_code(self) -> None: ("error", "`check_failed`", client), ("error", "`check_failed`", "`network_error`"), ("error", "`secret_key`", "`secret_key`"), + ("error", "`unsupported_key`", "`unsupported_key`"), ("GPS denied", "`gps_denied`", "`gps_denied`"), ("GPS denied", "`gps_unavailable`", "`gps_unavailable`"), ("GPS denied", "`gps_unavailable`", "`gps_unavailable`"), @@ -181,6 +182,7 @@ def test_each_failure_gives_a_state_a_message_and_an_event_code(self) -> None: ("GPS denied", "`gps_unavailable`", "`network_error`"), ("GPS denied", "`gps_not_found`", "no event"), ("error", "`secret_key`", "`secret_key`"), + ("error", "`unsupported_key`", "`unsupported_key`"), ], ) @@ -274,7 +276,7 @@ def test_each_rule_of_a_lookup_is_in_lookups(self) -> None: "The state `error` lasts until the text or a setting changes.", "A press of the location button cancels the lookup in progress, and the field" " forgets the remembered lookup.", - "It sends no request with that key, and it shows no location button.", + "It sends no request with a refused key, and it shows no location button.", ): with self.subTest(rule=rule): self.assertIn(rule, part) @@ -287,8 +289,8 @@ def test_each_rule_of_a_location_request_is_in_location(self) -> None: "A request that the field sends after a change of the key follows the rules for" " the new key.", "When the position arrives, the field sends `reverse` with the key that it holds then.", - "With a secret key, it sends nothing, shows the state `error` and raises the event" - " `error` with the code `secret_key`.", + "With a refused key, it sends nothing, shows the state `error` and raises the event" + " `error` with the code that Lookups gives.", "With no key, it sends nothing and shows the state that its text gives.", "The states `GPS coarse` and `GPS denied` last until the text changes.", ): @@ -308,7 +310,7 @@ def test_a_required_empty_field_that_shows_errors_is_invalid(self) -> None: "Or the text is empty, `required` is set and the field shows errors", states["invalid format"], ) - self.assertIn("a secret key", states["error"]) + self.assertIn("a secret key or an unsupported key", states["error"]) self.assertIn("`reverse`", states["error"]) def test_the_catalogue_says_when_the_device_gives_no_location(self) -> None: @@ -316,6 +318,28 @@ def test_the_catalogue_says_when_the_device_gives_no_location(self) -> None: self.assertIn("The device gave no location", description) +class KeyTest(unittest.TestCase): + lookups = section("## Lookups") + + def test_the_field_accepts_only_a_publishable_key(self) -> None: + for rule in ( + "The field accepts only a publishable key, which starts with `nipost_pk_`.", + "It reads the key without the white space at the start and the end.", + "It refuses a secret key, which starts with `nipost_test_` or `nipost_live_`," + " and an unsupported key, which is any other key.", + "The field logs one console error for the developer each time it reads a key" + " that it refuses.", + "The code is `secret_key` for a secret key and `unsupported_key` for an" + " unsupported key.", + ): + with self.subTest(rule=rule): + self.assertIn(rule, self.lookups) + + def test_an_unsupported_key_shows_the_text_of_a_secret_key(self) -> None: + self.assertEqual(messages()["unsupported_key"], messages()["secret_key"]) + self.assertNotIn("secret", catalogue()["@unsupported_key"]["description"]) + + class LocationTest(unittest.TestCase): part = section("## Location") diff --git a/standards/CODING_STANDARDS.md b/standards/CODING_STANDARDS.md index e017a1b..fa8acc0 100644 --- a/standards/CODING_STANDARDS.md +++ b/standards/CODING_STANDARDS.md @@ -144,7 +144,7 @@ The command exits with 0 when all files pass, with 1 when a rule is broken, and ## Security and privacy -- **SEC-1 MUST.** Client-side code refuses secret keys, which start with `nipost_test_` or `nipost_live_`. +- **SEC-1 MUST.** Client-side code accepts only publishable keys, which start with `nipost_pk_`. It refuses secret keys, which start with `nipost_test_` or `nipost_live_`, and any other key. - **SEC-2 MUST.** There is no telemetry. An SDK sends only the requests that the caller makes. - **SEC-3 MUST.** The cache is off by default. When on, it has a time limit and a clear command. - **SEC-4 MUST.** Call only the documented gateway endpoints. diff --git a/standards/languages/typescript.md b/standards/languages/typescript.md index 8a0e6e2..a0eb5ca 100644 --- a/standards/languages/typescript.md +++ b/standards/languages/typescript.md @@ -106,7 +106,7 @@ Prettier's `printWidth: 100` matches TELL-1. Prettier does not split long string - **TS-22 MUST.** Write an options object type inline as `Readonly<{ name?: Type }>`, and document it with one `@param options` line. The literal `readonly` form fails `eslint-plugin-jsdoc`, and a dotted `@param` name fails the TSDoc parser that API Extractor uses. - **TS-10 MUST.** An error class extends `Error`, sets `name` and passes `cause`. - **TS-11 MUST.** A `catch` block treats its value as `unknown` and narrows it. **(tool)** -- **TS-12 MUST.** Packages do not call `console`. The one exception is the console error of the field for a secret key, which `spec/field.md` asks for. **(tool)** +- **TS-12 MUST.** Packages do not call `console`. The one exception is the console error of the field for a key that it refuses, which `spec/field.md` asks for. **(tool)** - **TS-13 MUST.** Each `eslint-disable` comment gives a reason. **(tool)** - **TS-14 MUST.** Each package ships ESM with an `exports` map and type declarations. **(tool)** - **TS-15 MUST.** Browser code does not read `window`, `document` or `customElements` at import time. The field's guarded registration is the one exception.