diff --git a/pb_hooks/profile_pin.pb.js b/pb_hooks/profile_pin.pb.js
new file mode 100644
index 0000000..bd1cf2a
--- /dev/null
+++ b/pb_hooks/profile_pin.pb.js
@@ -0,0 +1,69 @@
+///
+
+// Server-side child-PIN verification (issues #13/#14 follow-up).
+//
+// `profiles.pinHash` is a hidden field *by design*: if any logged-in device
+// could read it, a child holding the TV could pull the salted SHA-256 and
+// brute-force a 4-digit PIN offline in milliseconds. The cost of that design:
+// a device that never saw the PIN being set (a TV signing in after the parent
+// set the PIN on the PC) has no local verifier and nothing readable to check
+// against — the gate always said "wrong PIN" there. This route closes the gap:
+// the client sends the candidate PIN, the server compares against the stored
+// hash and answers yes/no. The hash never leaves the server, and guessing is
+// rate-limited (5 failures per 5 minutes per account+profile) so the endpoint
+// can't be brute-forced either.
+//
+// The app mirrors a successful verify into its local secure storage, so each
+// device pays this round-trip at most once per profile (and works offline
+// afterwards) — see ProfileService.verifyPin.
+
+// ── POST /api/amber/verify-pin ────────────────────────────────────────────────
+// Auth: account owner. Body: { profileId, pin }. → { ok: bool } (429 when
+// rate-limited, with retryAfter seconds).
+routerAdd("POST", "/api/amber/verify-pin", (e) => {
+ const MAX_FAILS = 5
+ const WINDOW_SECONDS = 300
+
+ const data = new DynamicModel({ profileId: "", pin: "" })
+ e.bindBody(data)
+ const profileId = (data.profileId || "").trim()
+ const pin = (data.pin || "").trim()
+ if (profileId === "" || !/^\d{4}$/.test(pin)) {
+ return e.json(400, { ok: false, error: "profileId and a 4-digit pin are required" })
+ }
+
+ let rec = null
+ try {
+ rec = $app.findRecordById("profiles", profileId)
+ } catch (_) {
+ return e.json(404, { ok: false, error: "profile not found" })
+ }
+ // Owner-scoped, like the profiles collection rules.
+ if (rec.getString("user") !== e.auth.id) {
+ return e.json(404, { ok: false, error: "profile not found" })
+ }
+
+ // Fixed-window rate limit in the app store (shared across the isolated
+ // handler JSVMs — file-scope state is not, see device_auth.pb.js).
+ const store = $app.store()
+ const key = "amber.pinFails." + e.auth.id + "." + profileId
+ const now = Math.floor(Date.now() / 1000)
+ let st = store.get(key)
+ if (st && st.resetAt <= now) st = null
+ if (st && st.count >= MAX_FAILS) {
+ return e.json(429, { ok: false, error: "too many attempts", retryAfter: st.resetAt - now })
+ }
+
+ const stored = rec.getString("pinHash")
+ // Same shape the app stores: sha256(":") hex.
+ const ok = stored !== "" && stored === $security.sha256(profileId + ":" + pin)
+ if (ok) {
+ store.set(key, null)
+ } else {
+ store.set(key, {
+ count: (st ? st.count : 0) + 1,
+ resetAt: st ? st.resetAt : now + WINDOW_SECONDS,
+ })
+ }
+ return e.json(200, { ok: ok })
+}, $apis.requireAuth("users"))