Compare commits

...

5 commits

Author SHA1 Message Date
screentinker 3f9459139f
Gate licences in CI and publish an SBOM (#282)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Licence gate + SBOM (production deps) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The licence audit that found org.json in the APK was run by hand. Nothing stopped the next
transitive dependency arriving the same way, and "we track licences" was a claim rather than
something anyone could check.

TWO GATES, BOTH FAIL CLOSED.

scripts/license-check.js audits the server's npm tree. scripts/android-license-check.js
resolves the real releaseRuntimeClasspath — everything that can enter the APK a customer
installs — and checks it against android/licenses.json, where each entry records the licence
AND the evidence for it. A dependency nobody has recorded fails the build. That is the case
worth catching: org.json reached customers because it arrived transitively and nothing ever
asked what licence it carried.

Denied: AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence. Weak copyleft (LGPL,
MPL, EPL, CDDL) is reported but does not fail — it is a judgement, and the judgement should
be made by someone who knows they are making it. Anything unrecognised fails; a package whose
licence we cannot identify is not one we ship.

⚠️ THE SERVER GATE INSTALLS --omit=dev, AND THAT IS THE POINT. A developer checkout carries
sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later. It is a test fixture generator that
never reaches a server, but a scanner pointed at a dev tree reports LGPL and contradicts the
answer we give customers. Auditing the production install is what makes the answer defensible.

SBOM. Every release now publishes screentinker-sbom-<version>.cdx.json — CycloneDX 1.5, every
production dependency with version, purl and licence, generated from a production install. CI
uploads one on every run too. That is what turns the claim into something a customer or an
underwriter can verify themselves.

Neither script takes a dependency: a gate that needs its own supply chain audited is worth
less than one that does not.

Verified by mutation rather than assumed. Injecting GPL-3.0-or-later, AGPL-3.0, the JSON
Licence, SSPL-1.0, and a package with no licence field each fail the server gate; MIT and
LGPL pass (LGPL reported). Removing the org.json exclusion fails the Android gate by name;
dropping a group from the policy fails it as unrecorded. Both restored, both green.

Found and fixed while building it: npm ls exits non-zero for any tree problem — an extraneous
package is enough — which made the gate abort instead of auditing. It now reads the listing
either way and only aborts on genuinely empty output.

docs/licensing.md records the policy, how to run the gates, and the dev-vs-production trap.

1676/1676 pass.
2026-08-14 15:36:38 -05:00
screentinker 94a81b6896
Stop shipping a licence we would rather not have to explain (#281)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
An audit of what actually reaches customers turned up three things. None is copyleft — there
is no GPL or AGPL anywhere in the product — but two of them are the kind of detail that
undermines a claim to track licences at all.

org.json WAS BEING PACKAGED INTO THE APK. socket.io-client pulls org.json:json:20090211
transitively, and it was landing in the dex in full: 19 classes, including CDL, XML, JSONML
and the library's own Test class. That release carries the JSON Licence, whose "shall be used
for Good, not Evil" clause is not OSI-approved, is treated as non-free by Debian and Fedora,
and is Category X at Apache. Excluded now, and nothing is lost: Android has provided org.json
in the platform since API 1 and minSdk is 24.

Verified rather than assumed. The whole surface used — by socket.io/engine.io and by our own
Kotlin — is JSONObject, JSONArray and JSONTokener, via get/getString/getLong/getJSONArray/
getJSONObject/has/keys/length/isNull/put/NULL, the opt* family, and JSONTokener.nextValue.
Every one is platform API. The rebuilt APK defines 0 org.json classes (was 19) while still
referencing all three, so they now resolve against the platform; it is 25KB smaller and the
v1 JAR signature is intact. Then it was installed on a real panel, which registered over the
socket, paired, and parsed a playlist with no NoSuchMethodError — the failure mode that would
only ever appear at runtime.

REDOC SHIPPED WITH NO LICENCE NOTICE. frontend/vendor/redoc.standalone.js is in the release
tarball, minified with every header stripped, and the vendor README recorded version and
source but not licence. MIT requires the notice to travel with the software. Added as
redoc.LICENSE, with a note in the README that anything vendored here ships and therefore
needs one.

The server manifest declared no licence at all, though the repo is MIT — tooling and auditors
read package.json, not just the root LICENSE. Set, and the lockfile synced so `npm ci` cannot
disagree.

Audited against production's own installed tree rather than a developer checkout: 365
packages, `COPYLEFT — none`.

1676/1676 server tests, Android build + unit tests, `npm ci` clean.
2026-08-14 14:50:18 -05:00
ScreenTinker 86db5929c1 chore(release): v1.9.36 2026-08-14 12:23:16 -05:00
screentinker 3ec06b663c
Changelog for 1.9.36 (#280)
A crash on collector installs, and the check that should have caught it. Says plainly
who is affected (almost nobody: only a server configured to collect statistics from
other installs, not one reporting its own) so a self-hoster on 1.9.35 is not alarmed
into an unnecessary upgrade.
2026-08-14 12:22:36 -05:00
screentinker 8cb67122ad
Fix a load-time crash that took down any install collecting statistics (#279)
1.9.35 could not start with TELEMETRY_COLLECTOR=1. It threw before listening:

  ReferenceError: Cannot access 'db' before initialization
      at server.js:986

and systemd restarted it in a loop. Production was down until it was rolled back.

The mount passed the module-scope `db` to the collector's factory, but that binding is
declared ~275 lines further down. The inline handler this replaced only touched `db`
inside a request callback — which runs long after the binding exists — so moving the same
reference into a factory argument turned a lazy read into an eager one. Now resolved as
`require('./db/database').db`, the way every neighbouring call site in that region does it.

WHY NOTHING CAUGHT IT. The block is gated on a flag that only the statistics-collecting
deployment sets. It had therefore never executed in CI, on alpha, or in any test — 1676
tests, four green jobs, a clean alpha deploy, and the crashing line had still never run.
The unit tests mount the router directly and pass a db, which is precisely the part that
was fine.

So the boot smoke now boots WITH the collector enabled and asserts its routes answer:
/api/public/stats returns the expected shape, and a malformed report is refused with 400.
Booting alone would not be enough — the collector could mount and be broken.

Confirmed by reproduction: the released code fails to boot under that flag, and this does
not. 1676/1676 pass.
2026-08-14 12:17:41 -05:00
17 changed files with 620 additions and 11 deletions

View file

@ -86,6 +86,39 @@ jobs:
working-directory: android
run: ./gradlew :app:testDebugUnitTest --no-daemon
# Every artifact that can enter the APK must have a licence on file. This runs here
# rather than in its own job because the Gradle cache and Android SDK are already warm.
- name: Licence gate (APK runtime classpath)
run: node scripts/android-license-check.js
licenses:
name: Licence gate + SBOM (production deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
# --omit=dev on purpose, and it is the whole point of the job. A developer checkout
# carries sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later; it is a test
# fixture generator that never reaches a server. Auditing anything other than a
# production install would report a licence we do not actually ship.
- name: Install production dependencies only
working-directory: server
run: npm ci --omit=dev
- name: Licence gate
run: node scripts/license-check.js --sbom sbom/screentinker-server.cdx.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom/
if-no-files-found: error
smoke:
name: Boot smoke + version check
runs-on: ubuntu-latest
@ -109,6 +142,12 @@ jobs:
working-directory: server
env:
SELF_HOSTED: 'true'
# Boot WITH the collector on. This block is config-gated and only the
# statistics-collecting deployment sets the flag, so it had never executed in CI,
# on alpha, or in any test - and a load-time crash inside it took production down
# while every check was green. Code only one deployment runs is exactly the code
# CI has to execute.
TELEMETRY_COLLECTOR: '1'
run: |
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
echo $! > "$RUNNER_TEMP/server.pid"
@ -131,6 +170,21 @@ jobs:
test "$REPORTED" = "$EXPECTED"
echo "OK: status ok, version $REPORTED matches VERSION"
# Booting is not enough on its own - the collector could be mounted and broken. Prove
# the routes it adds actually answer, so a fault inside that block fails here rather
# than on the single deployment that turns it on.
- name: Assert the collector routes answer when enabled
run: |
STATS="$(curl -sf http://localhost:3001/api/public/stats)"
echo "stats: $STATS"
test "$(echo "$STATS" | jq -r 'has("screens") and has("installs")')" = "true"
REPORT="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' -d '{"bad":1}' \
http://localhost:3001/api/telemetry/report)"
echo "malformed report -> HTTP $REPORT"
test "$REPORT" = "400"
echo "OK: collector mounted and answering"
- name: Stop server
if: always()
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true

View file

@ -85,6 +85,15 @@ jobs:
./scripts/build-autorun-zip.sh -o autorun.zip
ls -la autorun.zip
# A published SBOM is what turns "we track licences" into something a customer or an
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
- name: Generate SBOM (production dependencies)
run: |
( cd server && npm ci --omit=dev )
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
ls -la screentinker-sbom-*.cdx.json
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
@ -154,6 +163,7 @@ jobs:
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
} > RELEASE_NOTES.md
cat RELEASE_NOTES.md
@ -171,6 +181,7 @@ jobs:
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
autorun.zip \
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
tizen/ScreenTinker.wgt
docker:

4
.gitignore vendored
View file

@ -59,3 +59,7 @@ audit/
# Local SQLite artifacts (any extension the tooling might produce)
*.sqlite
*.sqlite3
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
sbom/
*.cdx.json

View file

@ -1,5 +1,39 @@
# Changelog
## 1.9.36
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
### Fixed — 1.9.35 would not start on a server collecting install statistics
A server with install-statistics collection switched on could not start 1.9.35. It threw
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
and a service manager configured to restart it would do so in a loop.
**Almost nobody is affected.** The fault is inside a block that only runs when a server is configured
to *collect* statistics from other installs — not when it merely reports its own. That is a single
deployment, not a normal install. If you have never set `TELEMETRY_COLLECTOR`, 1.9.35 runs correctly
and this release changes nothing for you.
The cause was a reference to the database resolved when the file loaded rather than when the request
arrived, in code that had been moved earlier in the same release.
### Changed — the startup check now covers configuration only one deployment uses
The fault above shipped through a full test suite and every CI job green, because the affected block
is switched on by configuration that no test set. It had never executed anywhere except the one
server that turns it on.
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
actually answer. Code that only one deployment runs is exactly the code an automated check has to
exercise, and it now does.
### Upgrading
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
## 1.9.35
A maintenance release. Two faults where the product was working correctly and still looked broken to

View file

@ -1 +1 @@
1.9.35
1.9.36

View file

@ -13,8 +13,8 @@ android {
targetSdk = 34
// Env-overridable so device-owner reinstalls (which require an ever-increasing
// versionCode — downgrades are blocked) don't churn this file each build.
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "122").toInt()
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.35"
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "123").toInt()
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.36"
}
signingConfigs {
@ -87,8 +87,24 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
// Socket.IO client
implementation("io.socket:socket.io-client:2.1.0")
// Socket.IO client.
//
// org.json is excluded deliberately. socket.io-client pulls org.json:json:20090211
// transitively, and that artifact was being packaged into the APK in full — 19 classes,
// including CDL, XML, JSONML and its own Test class. It carries the JSON License, whose
// "shall be used for Good, not Evil" clause is not OSI-approved, is treated as non-free by
// Debian and Fedora, and is Category X at Apache. Shipping it in a commercially distributed
// binary is an avoidable licensing problem: it is not copyleft, but it is not a licence we
// want to have to explain.
//
// Nothing is lost. Android provides org.json in the platform (since API 1, and minSdk is 24),
// and the only classes either side actually touches are JSONObject, JSONArray and JSONTokener.
// The full method surface used — by socket.io/engine.io and by our own Kotlin — is
// get/getString/getLong/getJSONArray/getJSONObject/has/keys/length/isNull/put/NULL,
// the opt* family, and JSONTokener.nextValue. Every one is platform API.
implementation("io.socket:socket.io-client:2.1.0") {
exclude(group = "org.json", module = "json")
}
// WorkManager for background downloads
implementation("androidx.work:work-runtime-ktx:2.9.0")

45
android/licenses.json Normal file
View file

@ -0,0 +1,45 @@
{
"_comment": [
"Licence policy for everything on the Android release runtime classpath — i.e. everything that",
"can end up inside the APK customers install.",
"",
"scripts/android-license-check.js resolves the real classpath and checks it against this file.",
"An artifact that appears in neither 'artifacts' nor 'groups' FAILS: a new transitive dependency",
"must be looked at by a person before it ships, which is exactly how org.json:json:20090211 got",
"into the APK unnoticed in the first place.",
"",
"Record what you verified in 'evidence' — the point is to be able to answer 'how do you know?'"
],
"groups": {
"androidx": { "license": "Apache-2.0", "evidence": "AndroidX / Jetpack, Apache-2.0 across the board" },
"com.google.android.material": { "license": "Apache-2.0", "evidence": "Material Components for Android" },
"com.google.code.gson": { "license": "Apache-2.0", "evidence": "google/gson LICENSE" },
"com.google.crypto.tink": { "license": "Apache-2.0", "evidence": "google/tink LICENSE" },
"com.google.errorprone": { "license": "Apache-2.0", "evidence": "google/error-prone LICENSE" },
"com.google.guava": { "license": "Apache-2.0", "evidence": "google/guava LICENSE" },
"com.google.j2objc": { "license": "Apache-2.0", "evidence": "google/j2objc LICENSE" },
"com.squareup.okhttp3": { "license": "Apache-2.0", "evidence": "square/okhttp LICENSE" },
"com.squareup.okio": { "license": "Apache-2.0", "evidence": "square/okio LICENSE" },
"org.checkerframework": { "license": "MIT", "evidence": "checker-framework, MIT for the qualifiers" },
"org.jetbrains": { "license": "Apache-2.0", "evidence": "JetBrains annotations" },
"org.jetbrains.kotlin": { "license": "Apache-2.0", "evidence": "Kotlin stdlib" },
"org.jetbrains.kotlinx": { "license": "Apache-2.0", "evidence": "kotlinx coroutines" },
"io.socket": { "license": "MIT", "evidence": "socket.io-client-java LICENSE (MIT)" }
},
"artifacts": {},
"denied": {
"org.json:json": {
"why": "JSON Licence — the 'shall be used for Good, not Evil' clause. Not OSI-approved, non-free per Debian and Fedora, Apache Category X. Arrives transitively via socket.io-client and was previously packaged into the APK in full (19 classes). Excluded in app/build.gradle.kts; Android provides org.json in the platform from API 1 and minSdk is 24, so nothing is lost."
}
},
"denied_licenses": [
{ "match": "AGPL", "why": "network copyleft" },
{ "match": "GPL", "why": "strong copyleft in a commercially distributed binary" },
{ "match": "SSPL", "why": "not OSI-approved, service-scope obligations" },
{ "match": "JSON", "why": "field-of-use restriction" }
]
}

103
docs/licensing.md Normal file
View file

@ -0,0 +1,103 @@
# Licensing
ScreenTinker is MIT. This page records how we know what our dependencies are licensed under,
so the answer to "do you track licences?" is something you can check rather than something you
have to take on trust.
## The short answer
**No GPL or AGPL anywhere in the product.** Neither the server nor the Android player links,
bundles, or ships anything under strong or network copyleft.
## Where the answer comes from
Two gates run in CI on every push, and both fail closed — a dependency whose licence nobody has
recorded fails the build rather than shipping unnoticed.
| Gate | Covers | Script |
|---|---|---|
| Licence gate + SBOM (production deps) | the server's npm tree | `scripts/license-check.js` |
| Licence gate (APK runtime classpath) | everything that can enter the APK | `scripts/android-license-check.js` |
Run either locally:
```sh
cd server && npm ci --omit=dev && cd ..
node scripts/license-check.js # server
node scripts/android-license-check.js # APK
node scripts/license-check.js --sbom sbom/x.json # also write an SBOM
```
Neither script has dependencies of its own. A gate that needs its own supply chain audited is
worth less than one that doesn't.
## ⚠️ Audit the production install, not the checkout
**A licence scanner pointed at a developer checkout will report LGPL, and it will be wrong about
what we ship.**
`sharp` is a `devDependency` — a fixture generator for the image tests — and one of its platform
binaries, `@img/sharp-wasm32`, declares `Apache-2.0 AND LGPL-3.0-or-later AND MIT`. It is never
installed on a server: production installs with `npm ci --omit=dev`, which both the CI gate and
`scripts/upgrade.sh` use.
If someone challenges the answer with a scan of the repo, this is the discrepancy they have found.
`sharp` is kept deliberately: it is the *independent* implementation used to generate fixtures for
the pure-JavaScript image path that replaced it. Generating those fixtures with the library under
test would mean a decode bug could produce a fixture that hides the same bug.
## Policy
**Allowed** — MIT, MIT-0, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, Apache-2.0, BlueOak-1.0.0,
Unlicense, CC0-1.0, Python-2.0, WTFPL, Zlib, CC-BY-4.0.
**Denied** — AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence.
**Reported but not failed** — LGPL, MPL, EPL, CDDL, OSL, EUPL. Weak copyleft is file- or
library-scoped and usually fine when merely linked, but it is a judgement, and the judgement should
be made by someone who knows they are making it.
**Unrecognised — fails.** A package with no licence we can identify is not a package we ship. Where
a dependency ships a real licence *file* but declares no `license` field, it is recorded as an
exception in the script with the evidence that was read off disk (currently `exif-parser` and
`thirty-two`, both MIT).
### Why the JSON Licence is denied
`org.json:json:20090211` arrived transitively through `socket.io-client` and was **packaged into the
APK in full** — 19 classes, including ones nothing referenced. Its licence carries the clause *"The
Software shall be used for Good, not Evil"*: not OSI-approved, treated as non-free by Debian and
Fedora, and Category X at Apache. Not copyleft, but not a term to accept in a binary distributed
commercially.
It is now excluded in `android/app/build.gradle.kts`. Nothing is lost — Android has provided
`org.json` in the platform since API 1 and `minSdk` is 24 — and `android/licenses.json` denies it by
name so it cannot return quietly.
## SBOM
Every release publishes `screentinker-sbom-<version>.cdx.json`: **CycloneDX 1.5**, listing every
production dependency with its version, package URL, and licence. Generated from a production
install, so it describes what actually runs.
CI also uploads one as a build artifact on every run.
## Vendored code
Anything committed under `frontend/vendor/` **ships in the release tarball** and must carry its
licence notice as a separate file — minifiers strip headers, which is exactly when the notice has to
be kept alongside. See `frontend/vendor/README.md`.
## The GLSL transitions
The 14 shaders in `shared/Transitions/` are original work. Each carries its author and licence in
the file header, and none derives from Shadertoy, gl-transitions, glslsandbox or similar. "GL
Transitions v1" in those headers refers to the *interface convention* — the function signature the
renderer calls — not to borrowed code.
## Limits
These gates identify licences from declared metadata and recorded evidence. They are not a
clean-room provenance review, and they do not detect code copied into the repository without
attribution.

View file

@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: ScreenTinker Public API
version: 1.9.35
version: 1.9.36
description: |
Public, token-scoped REST API for ScreenTinker digital signage.

View file

@ -4,9 +4,16 @@ Third-party libraries committed directly to the repo (not fetched from a CDN or
from npm) so self-hosted / air-gapped instances work with no external dependency and no
build step.
**Anything added here ships in the release tarball**, so it must carry its licence notice —
a minified bundle usually has its headers stripped, which is exactly when the notice has to
be kept as a separate file next to it. Record the licence below and add a `<name>.LICENSE`.
## redoc.standalone.js
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
- **Version:** 2.3.9
- **Licence:** MIT — Copyright (c) 2015-present, Rebilly, Inc. Full text in
[`redoc.LICENSE`](redoc.LICENSE). The bundle itself carries no header (stripped by the
upstream minifier), which is why the notice is kept separately.
- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
- **Regenerate / update:**

31
frontend/vendor/redoc.LICENSE vendored Normal file
View file

@ -0,0 +1,31 @@
Redoc — https://github.com/Redocly/redoc
Version vendored here: 2.3.9 (see redoc.standalone.js)
The bundle in this directory is minified and its license headers were stripped upstream, so
the notice is kept alongside it instead. MIT requires this notice to accompany the software
wherever it is distributed, and redoc.standalone.js is included in the ScreenTinker release
tarball.
--------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2015-present, Rebilly, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
'use strict';
/*
* Licence gate for the APK.
*
* node scripts/android-license-check.js [--sbom <path>]
*
* Resolves the real `releaseRuntimeClasspath` every artifact that can end up inside the APK a
* customer installs, transitive ones included and checks each against android/licenses.json.
*
* Fails on an artifact nobody has recorded a licence for. That is the case worth catching:
* org.json:json:20090211 reached customers because it arrived as a transitive dependency of
* socket.io-client and nothing ever asked what licence it carried.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const ANDROID = path.join(ROOT, 'android');
const POLICY = JSON.parse(fs.readFileSync(path.join(ANDROID, 'licenses.json'), 'utf8'));
const SBOM_OUT = process.argv.includes('--sbom') ? process.argv[process.argv.indexOf('--sbom') + 1] : null;
function resolveClasspath() {
const out = execFileSync('./gradlew', ['-q', 'app:dependencies', '--configuration', 'releaseRuntimeClasspath'],
{ cwd: ANDROID, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' });
const found = new Map();
for (const raw of out.split('\n')) {
// Gradle prints "group:name:requested -> resolved" when a version is upgraded; the resolved
// one is what ships, so prefer the right-hand side.
const m = raw.match(/([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([0-9][a-zA-Z0-9._-]*)(?:\s*->\s*([0-9][a-zA-Z0-9._-]*))?/);
if (!m) continue;
const [, group, name, requested, upgraded] = m;
found.set(`${group}:${name}`, { group, name, version: upgraded || requested });
}
return [...found.values()].sort((a, b) => `${a.group}:${a.name}`.localeCompare(`${b.group}:${b.name}`));
}
function licenceFor(a) {
const coord = `${a.group}:${a.name}`;
if (POLICY.denied[coord]) return { verdict: 'DENY', why: POLICY.denied[coord].why };
if (POLICY.artifacts[coord]) return { verdict: 'ALLOW', ...POLICY.artifacts[coord] };
// Longest matching group prefix wins, so a specific rule beats a broad one.
const groups = Object.keys(POLICY.groups)
.filter(g => a.group === g || a.group.startsWith(g + '.'))
.sort((x, y) => y.length - x.length);
if (groups.length) return { verdict: 'ALLOW', ...POLICY.groups[groups[0]] };
return { verdict: 'UNKNOWN' };
}
const artifacts = resolveClasspath();
if (!artifacts.length) {
console.error('Resolved no artifacts — the gradle task did not run properly. Refusing to pass.');
process.exit(2);
}
const results = artifacts.map(a => ({ ...a, ...licenceFor(a) }));
const denied = results.filter(r => r.verdict === 'DENY');
const unknown = results.filter(r => r.verdict === 'UNKNOWN');
for (const r of results.filter(r => r.verdict === 'ALLOW')) {
for (const d of POLICY.denied_licenses) {
if (new RegExp(d.match, 'i').test(r.license)) { denied.push({ ...r, why: `${r.license}: ${d.why}` }); }
}
}
const counts = results.reduce((m, r) => (m[r.license || '(unrecorded)'] = (m[r.license || '(unrecorded)'] || 0) + 1, m), {});
console.log(`\nScope: ${results.length} artifacts on releaseRuntimeClasspath (everything that can enter the APK)\n`);
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
if (SBOM_OUT) {
const sbom = {
bomFormat: 'CycloneDX',
specVersion: '1.5',
version: 1,
metadata: {
component: {
type: 'application',
name: 'screentinker-android-player',
version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(),
licenses: [{ license: { id: 'MIT' } }],
},
},
components: results.map(r => ({
type: 'library',
name: `${r.group}:${r.name}`,
version: r.version,
purl: `pkg:maven/${r.group}/${r.name}@${r.version}`,
licenses: r.license ? [{ license: { id: r.license } }] : [],
})),
};
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
}
let failed = false;
if (denied.length) {
failed = true;
console.log('\nDENIED');
denied.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}\n ${r.why}`));
}
if (unknown.length) {
failed = true;
console.log('\nUNRECORDED — a new dependency reached the APK with no licence on file.');
console.log('Look it up, then add it to android/licenses.json with evidence, or exclude it.');
unknown.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}`));
}
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: every artifact in the APK has a recorded, permitted licence.\n');
process.exit(failed ? 1 : 0);

184
scripts/license-check.js Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env node
'use strict';
/*
* Licence gate for the dependencies that actually SHIP.
*
* node scripts/license-check.js [--sbom <path>] [--include-dev]
*
* Run from a PRODUCTION install (`npm ci --omit=dev`). That is the whole point: a developer
* checkout carries `sharp`, whose `@img/sharp-wasm32` declares LGPL-3.0-or-later. It is a test
* fixture generator, it is devDependencies-only, and it never reaches a server but a scanner
* pointed at a dev tree reports LGPL and contradicts the answer we give customers. Auditing the
* installed production tree is what makes the answer defensible.
*
* Exits non-zero on anything denied or unresolved, so CI fails before a licence can arrive
* unnoticed through a transitive bump.
*
* No dependencies, deliberately a gate that needs its own supply chain audited is worth less.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const args = process.argv.slice(2);
const INCLUDE_DEV = args.includes('--include-dev');
const SBOM_OUT = args.includes('--sbom') ? args[args.indexOf('--sbom') + 1] : null;
const SERVER_DIR = path.join(__dirname, '..', 'server');
/* policy
* ALLOW: permissive, no distribution obligation beyond keeping the notice.
* DENY: strong/network copyleft, plus licences we will not ship for other reasons.
* Anything matching neither is REVIEW it fails, and a human decides. Failing closed
* matters more than being clever: the risk is a licence arriving that nobody looked at.
*/
const ALLOW = [
/^MIT$/i, /^MIT-0$/i, /^ISC$/i, /^0BSD$/i, /^BSD-2-Clause$/i, /^BSD-3-Clause$/i,
/^Apache-2\.0$/i, /^BlueOak-1\.0\.0$/i, /^Unlicense$/i, /^CC0-1\.0$/i, /^Python-2\.0$/i,
/^WTFPL$/i, /^Zlib$/i, /^CC-BY-4\.0$/i,
];
const DENY = [
{ re: /\bAGPL/i, why: 'network copyleft — obligations trigger on serving, not distributing' },
{ re: /\bGPL-[123]|\bGPLv[123]|(^|[^L])\bGPL\b/i, why: 'strong copyleft — links into a product we distribute commercially' },
{ re: /\bSSPL/i, why: 'server-side public licence — not OSI-approved, service-scope obligations' },
{ re: /\bCommons-Clause/i, why: 'commercial-use restriction' },
{ re: /\bBUSL|Business Source/i, why: 'source-available, not open source' },
{ re: /Good, not Evil|^JSON$/i, why: 'JSON Licence — field-of-use clause, Apache Category X, non-free per Debian/Fedora' },
];
// Weak copyleft: file- or library-scoped, generally fine when merely linked, but never silently.
const REVIEW = [/\bLGPL/i, /\bMPL/i, /\bEPL/i, /\bCDDL/i, /\bOSL/i, /\bEUPL/i, /\bCPL/i];
/*
* Packages that ship a real licence FILE but declare no `license` field in package.json.
* Each entry records what was read off disk, so this is a documented finding rather than a
* blanket exemption. Re-verify if the version changes.
*/
const EXCEPTIONS = {
'exif-parser': { license: 'MIT', evidence: 'LICENSE.md — "The MIT License"' },
'thirty-two': { license: 'MIT', evidence: 'LICENSE.txt — MIT, Copyright (c) 2011 Chris Umbel' },
'screentinker': { license: 'MIT', evidence: 'repository root LICENSE' },
};
function classify(id) {
if (!id) return { verdict: 'UNKNOWN' };
for (const d of DENY) if (d.re.test(id)) return { verdict: 'DENY', why: d.why };
// A GPL-with-exception (Classpath, linking) is not the thing we are guarding against.
if (/WITH .*exception/i.test(id)) return { verdict: 'REVIEW', why: 'copyleft with a linking exception' };
for (const r of REVIEW) if (r.test(id)) return { verdict: 'REVIEW', why: 'weak copyleft' };
// Composite expressions: every term must be allowed.
const terms = id.split(/\s+(?:OR|AND)\s+|[()]/).map(s => s.trim()).filter(Boolean);
if (terms.length && terms.every(t => ALLOW.some(a => a.test(t)))) return { verdict: 'ALLOW' };
return { verdict: 'UNKNOWN' };
}
function readLicense(dir) {
let pkg;
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
let lic = pkg.license;
if (lic && typeof lic === 'object') lic = lic.type;
if (!lic && Array.isArray(pkg.licenses)) lic = pkg.licenses.map(l => l.type || l).join(' OR ');
return { name: pkg.name, version: pkg.version, license: lic || null };
}
/*
* `npm ls` exits non-zero for any tree problem an extraneous package, a peer-dep complaint
* while still printing the full listing. Treating that as fatal would turn a routine tree quirk
* into an unexplained CI failure, and worse, a licence check that never actually ran. Read the
* output either way; a genuinely empty result is the only thing worth aborting on.
*/
function listInstalled() {
const argv = ['ls', ...(INCLUDE_DEV ? [] : ['--omit=dev']), '--all', '--parseable'];
const opts = { cwd: SERVER_DIR, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' };
try {
return execFileSync('npm', argv, opts);
} catch (e) {
if (e.stdout && e.stdout.trim()) return e.stdout;
console.error('npm ls produced no output:\n' + (e.stderr || e.message));
process.exit(2);
}
}
const dirs = listInstalled().split('\n').filter(Boolean);
const pkgs = [];
const seen = new Set();
for (const d of dirs) {
const info = readLicense(d);
if (!info || !info.name) continue;
const key = `${info.name}@${info.version}`;
if (seen.has(key)) continue;
seen.add(key);
let license = info.license;
let note = null;
if (!license && EXCEPTIONS[info.name]) {
license = EXCEPTIONS[info.name].license;
note = `no license field; ${EXCEPTIONS[info.name].evidence}`;
}
pkgs.push({ ...info, license, note, ...classify(license) });
}
const denied = pkgs.filter(p => p.verdict === 'DENY');
const review = pkgs.filter(p => p.verdict === 'REVIEW');
const unknown = pkgs.filter(p => p.verdict === 'UNKNOWN');
const counts = pkgs.reduce((m, p) => (m[p.license || '(none)'] = (m[p.license || '(none)'] || 0) + 1, m), {});
console.log(`\nScope: ${pkgs.length} packages (${INCLUDE_DEV ? 'INCLUDING dev' : 'production only, --omit=dev'})\n`);
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
if (SBOM_OUT) {
// CycloneDX 1.5, hand-built. A standard format customers and underwriters recognise, without
// taking a dependency on a generator to produce it.
const sbom = {
bomFormat: 'CycloneDX',
specVersion: '1.5',
version: 1,
metadata: {
component: {
type: 'application',
name: 'screentinker',
version: fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(),
licenses: [{ license: { id: 'MIT' } }],
},
properties: [{ name: 'screentinker:scope', value: INCLUDE_DEV ? 'all' : 'production' }],
},
components: pkgs
.filter(p => p.name !== 'screentinker')
.sort((a, b) => a.name.localeCompare(b.name))
.map(p => ({
type: 'library',
name: p.name,
version: p.version,
purl: `pkg:npm/${p.name.replace('@', '%40')}@${p.version}`,
licenses: p.license ? [{ license: /[()]| OR | AND /.test(p.license) ? { name: p.license } : { id: p.license } }] : [],
})),
};
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
}
let failed = false;
if (denied.length) {
failed = true;
console.log('\nDENIED');
denied.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license}\n ${p.why}`));
}
if (unknown.length) {
failed = true;
console.log('\nUNRESOLVED — no recognised licence. Read the package, then add it to EXCEPTIONS');
console.log('with the evidence, or remove the dependency.');
unknown.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license || '(no license field)'}`));
}
if (review.length) {
// Not fatal, but never silent — weak copyleft is a judgement call, and the judgement should be
// made by a person who knows it is being made.
console.log('\nREVIEW (not failing)');
review.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license} ${p.why}`));
}
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: no denied or unresolved licences.\n');
process.exit(failed ? 1 : 0);

View file

@ -1,12 +1,13 @@
{
"name": "screentinker",
"version": "1.9.35",
"version": "1.9.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "screentinker",
"version": "1.9.35",
"version": "1.9.36",
"license": "MIT",
"dependencies": {
"@azure/msal-node": "^5.2.1",
"@jsquash/avif": "^1.3.0",

View file

@ -1,6 +1,7 @@
{
"name": "screentinker",
"version": "1.9.35",
"version": "1.9.36",
"license": "MIT",
"description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js",
"scripts": {

View file

@ -983,7 +983,13 @@ app.use('/api/status', require('./routes/status'));
* TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither.
*/
if (process.env.TELEMETRY_COLLECTOR === '1') {
app.use('/api', require('./routes/telemetry-collector')(db));
/* `require('./db/database').db`, not the module-scope `db` that binding is declared far
below this line, so naming it here throws "Cannot access 'db' before initialization" at
load and the process never starts. The inline handler this replaced only touched `db`
inside a request callback, which runs long after the binding exists; passing it to a
factory made the reference eager. Every neighbouring call site in this region resolves
the same lazy way. */
app.use('/api', require('./routes/telemetry-collector')(require('./db/database').db));
console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)');
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
id="http://screentinker.com/player" version="1.9.35" viewmodes="maximized">
id="http://screentinker.com/player" version="1.9.36" viewmodes="maximized">
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
<tizen:profile name="tv"/>
<name>ScreenTinker</name>