From c483ef34dd1929c3f2f7a697956a1842e578123e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 22:26:47 -0500 Subject: [PATCH] docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json, android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it had been frozen since the public API landed and integrators were reading a version identity that no longer existed. Spec changes: - info.version -> 1.9.25. - Device gains its two network addresses, which are easy to confuse and are now described so they cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as reported by the player, which is the one that reaches a panel on site. local_ip is new; both were returned by GET /devices and neither was documented. - Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram, cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web player does not report what Android does. - wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+ withholds the SSID without a location permission ScreenTinker only requests if an operator opts in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name. Drift prevention, because a wrong version number is silent and nobody re-reads one they trust: - bump-version.sh now writes the spec version too, anchored to info.version (operation- and schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected). - Three contract tests: the spec version tracks package.json, the two addresses stay documented and distinct, and the SSID sentinel stays explained. No new endpoints — audited every public router's routes against the spec and all are documented. 830 server tests + the 5 contract tests green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- docs/openapi.yaml | 56 +++++++++++++++++++++++++++- scripts/bump-version.sh | 13 ++++++- server/test/openapi-contract.test.js | 45 ++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d91ec8b..ee87082 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: ScreenTinker Public API - version: 1.9.0 + version: 1.9.25 description: | Public, token-scoped REST API for ScreenTinker digital signage. @@ -109,6 +109,60 @@ components: created_at: type: integer + # --- Network addresses ------------------------------------------------------------- + # Two different addresses, easy to confuse, so both are spelled out. They answer + # different questions and either can be null. + ip_address: + type: [string, "null"] + description: | + The device's **public (WAN)** address as observed by the server when the player + connected — i.e. what the internet sees. Behind a reverse proxy this is taken from + the first `X-Forwarded-For` entry, otherwise the socket peer address. Every device + on one site normally shares this. Null until the device has connected at least once. + local_ip: + type: [string, "null"] + description: | + The device's **own address on its local network** (e.g. `192.168.1.42`), as + reported by the player itself. This is the one to use to reach a panel directly on + site. Null on players that do not report it, or where the platform withholds it. + + # --- Latest telemetry -------------------------------------------------------------- + # Flattened from the most recent telemetry report. All null for a device that has + # never reported, and for platforms that cannot supply a given metric — treat every + # field here as optional rather than assuming a web player reports what Android does. + wifi_ssid: + type: [string, "null"] + description: | + Wi-Fi network name, or null on a wired/unknown connection. + + Special value `"permission"` means the device is on Wi-Fi but the operating system + withheld the name — on Android 10+ reading the SSID requires a location permission + that ScreenTinker only asks for if an operator opts in. Treat `"permission"` as + "connected, name unavailable", not as a network literally called that. + wifi_rssi: + type: [integer, "null"] + description: Signal strength in dBm (negative; closer to zero is stronger). + battery_level: + type: [integer, "null"] + description: Battery percentage 0-100, or null on mains-powered hardware. + battery_charging: + type: [integer, "null"] + description: 1 charging, 0 not charging, null unknown. + storage_free_mb: + type: [integer, "null"] + storage_total_mb: + type: [integer, "null"] + ram_free_mb: + type: [integer, "null"] + ram_total_mb: + type: [integer, "null"] + cpu_usage: + type: [number, "null"] + description: Recent CPU utilisation as a percentage, where the platform exposes it. + uptime_seconds: + type: [integer, "null"] + description: Seconds since the device booted. + Playlist: type: object properties: diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index c5f0bd2..d981e5f 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -80,8 +80,17 @@ sed -i -E "s/(versionCode.*\?:[[:space:]]*)\"[0-9]+\"/\1\"$((CODE + 1))\"/" andr NUMERIC="${NEW%%-*}" sed -i -E "/^<\?xml/! s/([[:space:]]version=\")[0-9][^\"]*(\")/\1${NUMERIC}\2/" tizen/config.xml -# 5) commit + annotated tag (no push) -git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml +# 5) public API spec version. This is the number Redoc prints at the top of the published +# API reference (frontend/api-docs.html renders docs/openapi.yaml directly), so leaving it +# behind means customers read a version that has not existed for months — it had drifted to +# 1.9.0 while shipping 1.9.25 precisely because this step did not exist. Anchored to the +# two-space ` version:` under `info:`; operation-level and schema-level keys are indented +# deeper and are not touched. As with Tizen, use the numeric form: the spec version is a +# published API identity, not a build label. +sed -i -E "0,/^ version:/s/^( version:[[:space:]]*).*/\1${NUMERIC}/" docs/openapi.yaml + +# 6) commit + annotated tag (no push) +git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml docs/openapi.yaml git commit -q -m "chore(release): v$NEW" git tag -a "v$NEW" -m "ScreenTinker v$NEW" diff --git a/server/test/openapi-contract.test.js b/server/test/openapi-contract.test.js index ad71a3d..185de59 100644 --- a/server/test/openapi-contract.test.js +++ b/server/test/openapi-contract.test.js @@ -53,3 +53,48 @@ test('openapi: every documented path is a token-reachable (public) router, never } assert.deepEqual(offenders, [], 'spec documents non-public paths:\n' + offenders.join('\n')); }); + +// The published spec version is what Redoc prints at the top of the API reference, so a stale +// value tells integrators they are reading docs for a release that no longer exists. It HAD gone +// stale — the spec said 1.9.0 while 1.9.25 was shipping — because bump-version.sh updated every +// other version source and not this one. That step now exists; this test is what keeps it honest, +// since the failure mode is silent and nobody reads a version number they already trust. +test('openapi: the spec version tracks the shipped release', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); + // Pre-release labels (1.9.26-beta.1) live on the build, not on the published API identity, + // so compare the numeric core the way bump-version.sh writes it. + const numeric = (v) => String(v).split('-')[0]; + assert.equal( + numeric(spec.info.version), + numeric(pkg.version), + 'docs/openapi.yaml info.version drifted from server/package.json — bump-version.sh should ' + + 'have moved both; if you edited a version by hand, move this one too', + ); +}); + +// Two addresses that are easy to mix up: ip_address is the public/WAN address the SERVER observed +// on connect, local_ip is the LAN address the PLAYER reported about itself. An integrator reaching +// a panel on site needs local_ip; one correlating sites needs ip_address. Both are returned by +// GET /devices, so both must be documented and must not be described interchangeably. +test('openapi: a device documents its WAN and LAN addresses distinctly', () => { + const props = spec.components.schemas.Device.properties; + for (const field of ['ip_address', 'local_ip']) { + assert.ok(props[field], `Device.${field} is returned by GET /devices but is not documented`); + assert.ok( + props[field].type.includes('null'), + `Device.${field} must be nullable — it is absent until a device reports/connects`, + ); + assert.ok(props[field].description, `Device.${field} needs a description to be told apart`); + } + assert.match(props.ip_address.description, /WAN|public/i); + assert.match(props.local_ip.description, /local network|LAN/i); +}); + +// "permission" is a sentinel, not a network name: Android 10+ withholds the SSID without a +// location permission ScreenTinker only requests if an operator opts in. An integrator who does +// not know that will render it as the Wi-Fi name to an end user. +test('openapi: the wifi_ssid permission sentinel is documented', () => { + const ssid = spec.components.schemas.Device.properties.wifi_ssid; + assert.ok(ssid, 'wifi_ssid is returned by GET /devices but is not documented'); + assert.match(ssid.description, /permission/, 'the sentinel value must be explained'); +});