ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:16:36 +10:00
commit 057da00fd2
406 changed files with 47822 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/* Digital Asset Links — what lets the staff app open threadcount.tech/my links itself.
*
* Without this file, tapping "Approve" in a manager's email opens Chrome rather than the app they
* installed. Android fetches it over HTTPS and checks that the certificate it names matches the
* one the installed app was signed with.
*
* The fingerprint has to be **Play's app-signing certificate**, not the upload key — with Play App
* Signing, Google re-signs the bundle, so the certificate on the device is theirs. Find it at
* Play Console → Test and release → Setup → App signing → "SHA-256 certificate fingerprint", and
* put it in the environment as ANDROID_APP_FINGERPRINTS. Several may be listed, comma-separated:
* both apps, or an old and a new key during a rotation.
*
* Served from an env var rather than a static file on purpose. The fingerprint is only knowable
* after the first upload, and a deploy is a cheaper way to add it than a code change — and if it
* is ever rotated, nothing here needs editing.
*
* With no fingerprint configured this returns an empty statement list, which is the honest answer:
* no app is authorised to handle these links yet, and Android falls back to the browser exactly as
* it does today. It never returns a malformed or guessed fingerprint.
*/
const PACKAGES: { name: string; label: string }[] = [
{ name: "tech.threadcount.staff", label: "ANDROID_APP_FINGERPRINTS_STAFF" },
{ name: "tech.threadcount.app", label: "ANDROID_APP_FINGERPRINTS_COUNTER" },
];
function fingerprints(specific: string): string[] {
const raw = process.env[specific] || process.env.ANDROID_APP_FINGERPRINTS || "";
return raw
.split(",")
.map((f) => f.trim().toUpperCase())
// A SHA-256 fingerprint is 32 colon-separated hex pairs. Anything else is a paste error, and
// shipping it would just make Android's verification fail silently.
.filter((f) => /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/.test(f));
}
export async function GET() {
const statements = PACKAGES.flatMap((p) => {
const fps = fingerprints(p.label);
if (!fps.length) return [];
return [{
relation: [
// Opens threadcount.tech/my links in the app instead of the browser.
"delegate_permission/common.handle_all_urls",
// Credential sharing: the site and the app are one account system, so a password saved on
// either autofills on the other. A staff member sets theirs once, on whichever surface the
// printed slip's link happened to open on, and shouldn't have to remember which.
"delegate_permission/common.get_login_creds",
],
target: { namespace: "android_app", package_name: p.name, sha256_cert_fingerprints: fps },
}];
});
return NextResponse.json(statements, {
headers: {
"content-type": "application/json",
// Android caches this; an hour is short enough that adding a fingerprint takes effect the
// same day, and long enough that it isn't fetched on every link tap.
"cache-control": "public, max-age=3600",
},
});
}