amber-backend/pb_migrations/1786500000_releases.js
Claude 785a621c49 Auto-update backend: releases collection + gated manifest (#16)
Adds the server side of auto-update (issue #16, was epic #6's [H]).

- pb_migrations/1786500000_releases.js: a superuser-only 'releases' collection
  (platform, variant, version, buildNumber, file, sha256, size, notes). Read is
  gated in the rules — any signed-in account sees clean builds; only an
  nsfwEnabled account sees adult ones — so PocketBase's native protected-file
  serving hands adult bytes only to flagged accounts (no custom streaming).

- pb_hooks/update.pb.js: GET /api/update/manifest?platform=… (auth). Picks the
  variant server-side from the caller's nsfwEnabled (adult) vs clean — the client
  can't request adult — and returns the latest build's version/buildNumber/notes/
  sha256/size + the protected downloadPath.

- scripts/publish-release.sh: uploads a built artifact as a superuser (computes
  sha256 + size, multipart POST). Token or email+password via env.

- docs/auto-update-contract.md: the collection, endpoint, download flow, gating.

Migration + hook + script syntax-checked. Live verification pends deploying this
to the PB (collection auto-applies on boot, hook loads from pb_hooks/).
2026-07-20 11:36:03 +02:00

55 lines
2.9 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// Auto-update release catalog (epic #6, issue #16).
//
// One row per published build, keyed by (platform, variant, buildNumber). The
// app's updater asks `/api/update/manifest` which build is latest for its
// platform; the server picks the VARIANT from the caller's admin-set
// `nsfwEnabled` (adult) vs clean — the client never chooses.
//
// Gating lives in the access rules: any signed-in user can read CLEAN releases;
// only an `nsfwEnabled` account can read ADULT ones. Because the view rule needs
// auth, PocketBase serves the attached file as a *protected* file (a short-lived
// file token is required, and minting one re-checks the same rule) — so adult
// bytes only ever reach flagged accounts, with no custom file streaming. Writes
// are superuser-only (publish goes through the admin API; see scripts/publish).
migrate((app) => {
const releases = new Collection({
type: "base",
name: "releases",
// Read: signed-in; adult rows only for nsfwEnabled accounts. The file
// inherits this (protected → download needs a file token that re-checks it).
listRule: "@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)",
viewRule: "@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)",
// Publish / edit / remove: superuser only (null → not exposed to clients).
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "select", name: "platform", required: true, maxSelect: 1, values: ["windows", "linux", "android"] },
{ type: "select", name: "variant", required: true, maxSelect: 1, values: ["clean", "adult"] },
// Human semver shown to the user, e.g. "1.0.1". buildNumber is the
// monotonic comparator the updater actually compares against.
{ type: "text", name: "version", required: true, max: 40 },
{ type: "number", name: "buildNumber", required: true, min: 1, onlyInt: true },
// The artifact: desktop = a .zip of the release bundle, android = the .apk.
{ type: "file", name: "file", required: true, maxSelect: 1, maxSize: 524288000 },
// Lowercase hex SHA-256 of the artifact; the app verifies it before install.
{ type: "text", name: "sha256", required: true, min: 64, max: 64 },
{ type: "number", name: "size", required: true, min: 0, onlyInt: true },
{ type: "text", name: "notes", required: false, max: 4000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX `idx_releases_pvb` ON `releases` (`platform`, `variant`, `buildNumber`)",
],
})
app.save(releases)
}, (app) => {
// ---- Down migration ----------------------------------------------------
try {
app.delete(app.findCollectionByNameOrId("releases"))
} catch (_) { /* already gone */ }
})