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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user