From c719caa79a4bb662b53a1c7309450ee8fd1e4dde Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 8 Oct 2026 13:23:37 -0700 Subject: [PATCH] AC network device registry; Whistlegraph 115 reports its devices shared/app-registry.mjs lists every app that reports devices or receives notifications (Whistlegraph wired; AC, Aesel, Oskiewar, Menu Band and sotce.net ready to hook in). shared/app-devices.mjs keeps one Mongo row per app + device in app-devices: verified account and handle, build, version, model, OS, first/last seen, opens, and later its push registration and topics. Rows do not expire; account deletion removes them. /api/app-device takes reports (bearer token optional; the app's Auth0 tenant verifies it), lists your own devices, and removes one. toolchain/devices.mjs shows devices by app, person and build. Whistlegraph 115 reports open on every activation, login when the account verifies, and logout on sign-out, using identifierForVendor. The privacy text now says so and drops the retired personal-model lines. --- .../whistlegraph/Sources/DeviceRegistry.swift | 46 ++++++ .../Sources/WhistlegraphApp.swift | 6 +- .../Sources/WhistlegraphPrivacy.swift | 6 +- .../Whistlegraph.xcodeproj/project.pbxproj | 10 +- apple/whistlegraph/project.yml | 2 +- shared/app-devices.mjs | 145 ++++++++++++++++++ shared/app-devices.test.mjs | 60 ++++++++ shared/app-registry.mjs | 30 ++++ system/backend/account-deletion.mjs | 1 + system/netlify/functions/app-device.mjs | 85 ++++++++++ toolchain/devices.mjs | 64 ++++++++ 11 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 apple/whistlegraph/Sources/DeviceRegistry.swift create mode 100644 shared/app-devices.mjs create mode 100644 shared/app-devices.test.mjs create mode 100644 shared/app-registry.mjs create mode 100644 system/netlify/functions/app-device.mjs create mode 100644 toolchain/devices.mjs diff --git a/apple/whistlegraph/Sources/DeviceRegistry.swift b/apple/whistlegraph/Sources/DeviceRegistry.swift new file mode 100644 index 0000000000..b5fdfeae27 --- /dev/null +++ b/apple/whistlegraph/Sources/DeviceRegistry.swift @@ -0,0 +1,46 @@ +import Foundation +import UIKit + +/// Reports this device to the AC network device registry (POST /api/app-device): +/// app build and version, model, iOS version, and, when signed in, the account +/// (verified server-side from the bearer token). Each activation counts as an open. +/// The device id is identifierForVendor, which survives reinstalls while any +/// computer.aesthetic app remains installed. Debug builds stay silent unless +/// WHISTLEGRAPH_DEVICE_REPORTS=1, so fixtures and UI tests never register. +@MainActor enum DeviceRegistry { + enum Event: String { case open, seen, login, logout, push } + + static func report(_ event: Event, account: WhistlegraphAccount?, push: [String: Any]? = nil) { + #if DEBUG + guard ProcessInfo.processInfo.environment["WHISTLEGRAPH_DEVICE_REPORTS"] == "1" else { return } + #endif + guard let deviceId = UIDevice.current.identifierForVendor?.uuidString else { return } + let info = Bundle.main.infoDictionary ?? [:] + var body: [String: Any] = [ + "app": "whistlegraph", "deviceId": deviceId, "event": event.rawValue, + "platform": UIDevice.current.userInterfaceIdiom == .pad ? "ipados" : "ios", + "model": machine, "os": UIDevice.current.systemVersion, "label": UIDevice.current.model, + ] + if let version = info["CFBundleShortVersionString"] as? String { body["version"] = version } + if let build = info["CFBundleVersion"] as? String { body["build"] = build } + if let push { body["push"] = push } + Task { + // Logout is reported after the Keychain sign-in is gone; it unbinds unsigned. + let token = event == .logout ? nil : try? await account?.token() + var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/app-device")!, timeoutInterval: 10) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + _ = try? await URLSession.shared.data(for: request) // best effort; the next open retries + } + } + + /// Hardware identifier such as "iPhone15,3". + private static let machine: String = { + var info = utsname(); uname(&info) + return withUnsafeBytes(of: &info.machine) { raw in + String(decoding: raw.prefix(while: { $0 != 0 }), as: UTF8.self) + } + }() +} diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index 3cf70e66bf..c0b4b81823 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -67,6 +67,7 @@ struct WhistlegraphApp: App { DeviceActionLog.shared.record(.lifecycle, value == .active ? .active : value == .background ? .background : .inactive) if value == .background { voice.cancelHold() } if value == .active && voice.capturePhase == .idle { voice.resumePieceAudio() } + if value == .active && !voice.isConsentFixture && !voice.accountEntryTest { DeviceRegistry.report(.open, account: voice.account) } if value == .active && !voice.isConsentFixture && !voice.accountEntryTest { Task { await TezDisplayRate.shared.refresh() @@ -246,7 +247,9 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand private var acceptedAIConsent = false @Published private(set) var localDataRevision = 0 let aiConsent = AIConsent.shared - @Published var accountStatus: AccountEntryStatus = .checking + @Published var accountStatus: AccountEntryStatus = .checking { + didSet { if accountStatus == .ready && oldValue != .ready { DeviceRegistry.report(.login, account: account) } } + } @Published var accountNotice = "" @Published var engineReady = false private var performanceTurn = false @@ -579,6 +582,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand func signOut() { guard capturePhase == .idle else { return } account.signOut() + DeviceRegistry.report(.logout, account: nil) accountStatus = .signedOut; accountNotice = "" aiConsent.bind(subject: nil, handle: "") emitEngine(["kind": "account", "token": ""]) diff --git a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift index 95e55c6db1..21867b9368 100644 --- a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift +++ b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift @@ -47,7 +47,7 @@ struct WhistlegraphAIConsentSheet: View { .accessibilityAddTraits(.isHeader) Text("To make and check your piece, AC shares your typed and spoken words, drawings, code and version history, sound measurements and artwork previews with OpenRouter and your chosen AI provider.") .font(.custom("ComicRelief-Regular", size: 18, relativeTo: .body)) - Text("Providers: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba (Qwen), MiniMax and Z.ai. Personal models use Anthropic or OpenAI directly.") + Text("Providers: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba (Qwen), MiniMax and Z.ai.") .font(.custom("ComicRelief-Regular", size: 15, relativeTo: .subheadline)) Text("Change your choice in Brain → AI & privacy.") .font(.custom("ComicRelief-Regular", size: 15, relativeTo: .subheadline)) @@ -98,7 +98,7 @@ struct WhistlegraphPrivacySheet: View { List { Section("AI creation") { Text("AC sends your prompts, speech transcripts, selected source and version context, drawings, requested sound measurements, and cropped artwork preview images to AI services to generate and check edits.") - Text("Hosted models use OpenRouter and the model provider you select: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba/Qwen, MiniMax, or Z.ai. Personal models use Anthropic or OpenAI. The Brain panel identifies the current model and service.") + Text("Hosted models use OpenRouter and the model provider you select: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba/Qwen, MiniMax, or Z.ai. The Brain panel identifies the current model and service.") Toggle("Allow AI creation", isOn: Binding(get: { consent.creation }, set: { consent.set(\.creation, $0) })) .disabled(!consent.signedIn).accessibilityIdentifier("privacy-ai-creation") } @@ -114,7 +114,7 @@ struct WhistlegraphPrivacySheet: View { } Section { Text("Turning permission off stops new requests and cancels active sending. Data already sent may remain with those services under their policies. Viewing, editing source and exporting your saved work remain available.") - Text("Your AC account privately stores source, version history, requests and diagnostic receipts. Saved microphone recordings remain on this phone unless cloud speech is enabled. Avoid sending sensitive personal information.") + Text("Your AC account privately stores source, version history, requests and diagnostic receipts. It also keeps a record of each device you use Whistlegraph on — app build, device model, iOS version and when it last opened — so AC can support you and send notifications you allow. Saved microphone recordings remain on this phone unless cloud speech is enabled. Avoid sending sensitive personal information.") Link("Privacy policy", destination: privacy).accessibilityIdentifier("privacy-policy") Link("Contact support", destination: URL(string: "mailto:mail@aesthetic.computer")!) if !consent.signedIn { Text("Sign in to manage this account's AI permissions.").foregroundStyle(.secondary) } diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index 810a190065..da1f3493ff 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 47DADA794ABD0CD203CFF48C /* StoryExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B494AD8CD86FCC5DE0E4CC /* StoryExport.swift */; }; 49D6E6ED3128002039D4F931 /* UtteranceRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9B92C3457F07429C8C2600 /* UtteranceRecording.swift */; }; 4A8B9BCE5403659FFB71EA65 /* AccountDeletionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 725C2A9ACD0323F49EFFDFC1 /* AccountDeletionClient.swift */; }; + 4ABB5050941AD905930711B7 /* DeviceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325762318FCE1CB2986C5B1F /* DeviceRegistry.swift */; }; 4B35B066F49BDD9BD8B90B5A /* WhistlegraphScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66B3ADC4D6D837FABC53B8F /* WhistlegraphScreen.swift */; }; 55A7061DE3B5F747C7BE96CE /* WhistlegraphBraincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BFA0F39F2F514E799291F6 /* WhistlegraphBraincells.swift */; }; 561DCBD95EB8AECF60476DE7 /* WhistlegraphTV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DACE2604B34CCB5EB3EE8AC /* WhistlegraphTV.swift */; }; @@ -108,6 +109,7 @@ 2AB3C6140E5D943C3363C353 /* WhistlegraphSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphSource.swift; sourceTree = ""; }; 30AAFA5A6A0627C23CA67DA4 /* WhistlegraphPrivacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphPrivacy.swift; sourceTree = ""; }; 31AAC74C07F21688CEFF544F /* PieceAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PieceAudio.swift; sourceTree = ""; }; + 325762318FCE1CB2986C5B1F /* DeviceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistry.swift; sourceTree = ""; }; 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputButtonLabels.swift; sourceTree = ""; }; 367C82326F5862EB85ED3260 /* DeviceActionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceActionLog.swift; sourceTree = ""; }; 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSheetsTests.swift; sourceTree = ""; }; @@ -203,6 +205,7 @@ 10E2AAA0A3F2BA1A5AEBED58 /* CodeTicker.swift */, 919CF3F0659F42F321B22145 /* CostDisplay.swift */, 367C82326F5862EB85ED3260 /* DeviceActionLog.swift */, + 325762318FCE1CB2986C5B1F /* DeviceRegistry.swift */, 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */, 7848DB9FED6B955BC193F650 /* LiveTranscription.swift */, 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */, @@ -422,6 +425,7 @@ 86581AE590F8A301268220CA /* CostDisplay.swift in Sources */, 6460BCE9CE823AF90631AE50 /* CurtainAccountButtonStyle.swift in Sources */, 6037259BAADEA55B4760A19A /* DeviceActionLog.swift in Sources */, + 4ABB5050941AD905930711B7 /* DeviceRegistry.swift in Sources */, 123228FADA6696AB354306C7 /* InputButtonLabels.swift in Sources */, 5D29324F2AF09B53070D7644 /* LiveTranscription.swift in Sources */, 14FFBE48767E2576693FB7B3 /* MusicalInput.swift in Sources */, @@ -622,7 +626,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 114; + CURRENT_PROJECT_VERSION = 115; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -707,7 +711,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 114; + CURRENT_PROJECT_VERSION = 115; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; @@ -767,7 +771,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 114; + CURRENT_PROJECT_VERSION = 115; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index e9542a128a..6b68d22065 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -16,7 +16,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "114" + CURRENT_PROJECT_VERSION: "115" targets: Whistlegraph: type: application diff --git a/shared/app-devices.mjs b/shared/app-devices.mjs new file mode 100644 index 0000000000..a14e4c59ce --- /dev/null +++ b/shared/app-devices.mjs @@ -0,0 +1,145 @@ +// App devices — the AC network's device registry, in Mongo ("app-devices"). +// +// One row per app + device: who is signed in on it, which build it runs, when +// it last opened, and (once the person allows notifications) how to reach it. +// It answers "who is on build 114, on which device" and it is the target list +// for notifications to a device, a person, or a group (topic). +// +// _id ":" +// app a key of APPS (app-registry.mjs) +// deviceId stable per app install: iOS identifierForVendor, or a UUID the +// client keeps (web, desktop) +// user verified Auth0 key (sotce users "sotce-" + sub), or absent +// handle handle at last verified report, without "@" +// platform, version, build, model, os, label +// firstAt, lastSeenAt, lastOpenAt, opens +// push { kind: "apns", token, env } | { kind: "webpush", subscription } +// topics group names this device receives, e.g. "testers" +// +// Rows do not expire: devices are kept until the app removes them, the push +// service reports them gone, or the account is deleted. +import { APPS, PLATFORMS, appConfig } from "./app-registry.mjs"; + +export const APP_DEVICES = "app-devices"; +export const EVENTS = Object.freeze(["open", "seen", "login", "logout", "push"]); + +const DEVICE_ID = /^[A-Za-z0-9-]{8,128}$/; +const VERSION = /^\d+(?:\.\d+){0,3}$/; +const BUILD = /^\d{1,9}$/; +const TOPIC = /^[a-z0-9][a-z0-9:-]{0,39}$/; +const APNS_TOKEN = /^[0-9a-fA-F]{32,512}$/; +const text = (value, max) => typeof value === "string" ? value.slice(0, max) : ""; + +function normalizePush(push, app) { + if (push === null) return null; // permission revoked or token dropped + if (push === undefined) return undefined; + if (push?.kind === "apns" && appConfig(app)?.apns && APNS_TOKEN.test(push.token ?? "")) { + return { kind: "apns", token: push.token.toLowerCase(), env: push.env === "sandbox" ? "sandbox" : "production" }; + } + const sub = push?.subscription; + if (push?.kind === "webpush" && appConfig(app)?.web && typeof sub?.endpoint === "string" && + sub.endpoint.startsWith("https://") && typeof sub.keys?.p256dh === "string" && typeof sub.keys?.auth === "string") { + return { kind: "webpush", subscription: { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } } }; + } + throw Object.assign(new Error("Invalid push registration"), { statusCode: 400 }); +} + +// Validate a client report. Throws a 400-shaped error on anything malformed. +export function normalizeReport(body) { + const fail = message => { throw Object.assign(new Error(message), { statusCode: 400 }); }; + if (!body || typeof body !== "object") fail("Invalid report"); + if (!Object.hasOwn(APPS, body.app)) fail("Unknown app"); + if (!DEVICE_ID.test(body.deviceId ?? "")) fail("Invalid deviceId"); + if (!PLATFORMS.includes(body.platform)) fail("Invalid platform"); + if (!EVENTS.includes(body.event)) fail("Invalid event"); + if (body.version !== undefined && !VERSION.test(body.version)) fail("Invalid version"); + if (body.build !== undefined && !BUILD.test(String(body.build))) fail("Invalid build"); + let topics; + if (body.topics !== undefined) { + if (!Array.isArray(body.topics) || body.topics.length > 16 || !body.topics.every(t => TOPIC.test(t))) fail("Invalid topics"); + topics = [...new Set(body.topics)]; + } + return { + app: body.app, deviceId: body.deviceId, platform: body.platform, event: body.event, + version: body.version, build: body.build === undefined ? undefined : String(body.build), + model: text(body.model, 40), os: text(body.os, 40), label: text(body.label, 64), + push: normalizePush(body.push, body.app), topics, + }; +} + +export const deviceKey = (app, deviceId) => `${app}:${deviceId}`; + +// The Mongo update for one report. `user`/`handle` come from the verified +// token only; an unsigned report never binds or unbinds an account, except +// "logout", which unbinds this device. +export function reportUpdate(report, { user = null, handle = null, now = new Date() } = {}) { + const set = { platform: report.platform, lastSeenAt: now }; + for (const field of ["version", "build", "model", "os", "label"]) if (report[field]) set[field] = report[field]; + if (report.topics) set.topics = report.topics; + if (user) { set.user = user; if (handle) set.handle = handle; } + if (report.event === "open") set.lastOpenAt = now; + if (report.push) set.push = { ...report.push, updatedAt: now }; + const unset = {}; + if (report.event === "logout") { unset.user = ""; unset.handle = ""; } + if (report.push === null) unset.push = ""; + return { + $setOnInsert: { app: report.app, deviceId: report.deviceId, firstAt: now, ...(report.topics ? {} : { topics: [] }) }, + $set: set, + ...(Object.keys(unset).length ? { $unset: unset } : {}), + ...(report.event === "open" ? { $inc: { opens: 1 } } : {}), + }; +} + +let indexed = new WeakSet(); +export async function ensureIndexes(collection) { + if (indexed.has(collection)) return; + await Promise.all([ + collection.createIndex({ app: 1, user: 1 }), + collection.createIndex({ app: 1, topics: 1 }), + collection.createIndex({ app: 1, build: 1 }), + collection.createIndex({ "push.token": 1 }, { sparse: true }), + collection.createIndex({ user: 1 }), + ]); + indexed.add(collection); +} + +export async function recordReport(collection, report, identity = {}) { + await ensureIndexes(collection); + const _id = deviceKey(report.app, report.deviceId); + // A push token belongs to one device row; a reinstall or account switch + // that reuses it must not leave a second row delivering to the same phone. + if (report.push?.kind === "apns") { + await collection.updateMany({ "push.token": report.push.token, _id: { $ne: _id } }, { $unset: { push: "" } }); + } + const update = reportUpdate(report, identity); + try { + await collection.updateOne({ _id }, update, { upsert: true }); + } catch (error) { + if (error.code !== 11000) throw error; // two first reports raced the upsert + await collection.updateOne({ _id }, update); + } + return _id; +} + +// Mongo filter for a notification target. +// { app, deviceId } one device +// { app?, user } every device a person is signed in on (optionally one app) +// { app, topic } a group +export function targetFilter(target) { + if (target?.deviceId) { + if (!target.app) throw new Error("A device target needs its app"); + return { _id: deviceKey(target.app, target.deviceId) }; + } + if (target?.user) return { user: target.user, ...(target.app ? { app: target.app } : {}) }; + if (target?.topic) { + if (!target.app || !TOPIC.test(target.topic)) throw new Error("A topic target needs its app and a valid topic"); + return { app: target.app, topics: target.topic }; + } + throw new Error("Unknown notification target"); +} + +// What a person may see about their own devices. +export const OWN_DEVICE_FIELDS = Object.freeze({ + _id: 0, app: 1, deviceId: 1, platform: 1, version: 1, build: 1, model: 1, os: 1, label: 1, + firstAt: 1, lastSeenAt: 1, lastOpenAt: 1, opens: 1, topics: 1, "push.kind": 1, "push.env": 1, +}); diff --git a/shared/app-devices.test.mjs b/shared/app-devices.test.mjs new file mode 100644 index 0000000000..afb70f388f --- /dev/null +++ b/shared/app-devices.test.mjs @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { APPS, appConfig, userKey } from "./app-registry.mjs"; +import { normalizeReport, reportUpdate, targetFilter, deviceKey } from "./app-devices.mjs"; + +const base = { app: "whistlegraph", deviceId: "6F9619FF-8B86-D011-B42D-00C04FC964FF", platform: "ios", event: "open", version: "0.1.0", build: 114 }; +const now = new Date("2026-10-08T20:00:00Z"); + +test("every app names a tenant; sotce users are prefixed", () => { + for (const [id, app] of Object.entries(APPS)) assert.ok(["aesthetic", "sotce"].includes(app.tenant), id); + assert.equal(userKey("whistlegraph", "auth0|1"), "auth0|1"); + assert.equal(userKey("sotce-net", "auth0|1"), "sotce-auth0|1"); + assert.equal(appConfig("nope"), null); +}); + +test("reports are validated before they reach Mongo", () => { + const ok = normalizeReport(base); + assert.equal(ok.build, "114"); + for (const bad of [{ app: "nope" }, { deviceId: "x" }, { platform: "amiga" }, { event: "boom" }, { version: "1.x" }, + { build: "11a" }, { topics: ["Bad Topic"] }, { push: { kind: "apns", token: "zz" } }, { push: { kind: "webpush" } }]) + assert.throws(() => normalizeReport({ ...base, ...bad }), e => e.statusCode === 400, JSON.stringify(bad)); + // Web Push only for apps with a web surface; APNs only for apps with a bundle. + const sub = { endpoint: "https://push.example/x", keys: { p256dh: "p", auth: "a" } }; + assert.throws(() => normalizeReport({ ...base, push: { kind: "webpush", subscription: sub } })); + assert.equal(normalizeReport({ ...base, app: "sotce-net", platform: "web", push: { kind: "webpush", subscription: sub } }).push.kind, "webpush"); + assert.throws(() => normalizeReport({ ...base, app: "sotce-net", platform: "web", push: { kind: "apns", token: "ab".repeat(32) } })); + assert.equal(normalizeReport({ ...base, push: { kind: "apns", token: "AB".repeat(32), env: "sandbox" } }).push.token, "ab".repeat(32)); + assert.equal(normalizeReport({ ...base, push: null }).push, null); +}); + +test("an open counts, binds the verified account, and records the build", () => { + const update = reportUpdate(normalizeReport(base), { user: "auth0|artur", handle: "dreamdeal", now }); + assert.deepEqual(update.$inc, { opens: 1 }); + assert.equal(update.$set.user, "auth0|artur"); + assert.equal(update.$set.handle, "dreamdeal"); + assert.equal(update.$set.build, "114"); + assert.equal(update.$set.lastOpenAt, now); + assert.equal(update.$setOnInsert.firstAt, now); +}); + +test("an unsigned report never binds or unbinds; logout unbinds; revoked push unsets", () => { + const anon = reportUpdate(normalizeReport({ ...base, event: "seen" }), { now }); + assert.equal(anon.$set.user, undefined); + assert.equal(anon.$unset, undefined); + assert.equal(anon.$inc, undefined); + const out = reportUpdate(normalizeReport({ ...base, event: "logout" }), { now }); + assert.deepEqual(out.$unset, { user: "", handle: "" }); + const revoked = reportUpdate(normalizeReport({ ...base, event: "push", push: null }), { now }); + assert.deepEqual(revoked.$unset, { push: "" }); +}); + +test("notification targets: device, person, group", () => { + assert.deepEqual(targetFilter({ app: "whistlegraph", deviceId: "abc12345" }), { _id: deviceKey("whistlegraph", "abc12345") }); + assert.deepEqual(targetFilter({ user: "auth0|1" }), { user: "auth0|1" }); + assert.deepEqual(targetFilter({ user: "auth0|1", app: "aesel" }), { user: "auth0|1", app: "aesel" }); + assert.deepEqual(targetFilter({ app: "whistlegraph", topic: "testers" }), { app: "whistlegraph", topics: "testers" }); + assert.throws(() => targetFilter({ deviceId: "abc12345" })); + assert.throws(() => targetFilter({ topic: "testers" })); + assert.throws(() => targetFilter({})); +}); diff --git a/shared/app-registry.mjs b/shared/app-registry.mjs new file mode 100644 index 0000000000..2f23590fee --- /dev/null +++ b/shared/app-registry.mjs @@ -0,0 +1,30 @@ +// App registry — every app that reports devices or receives pushes. +// One row per app. Adding an app is adding a row here; the device registry +// (app-devices.mjs), /api/app-device and push routing all read this table. +// +// tenant Auth0 tenant that signs the app's users in ("aesthetic" | "sotce"). +// sotce users are stored as "sotce-" + sub, the repo-wide convention. +// apns APNs topic (bundle id) for native Apple builds, or null. +// web true when the app is a web/PWA surface that registers Web Push. +// wired true once the app's client actually reports. Others are ready to +// hook in; the endpoint accepts them already. +export const APPS = Object.freeze({ + whistlegraph: { tenant: "aesthetic", apns: "computer.aesthetic.walkieware", web: false, wired: true }, + aestheticcomputer: { tenant: "aesthetic", apns: "aesthetic.computer", web: true, wired: false }, + aesel: { tenant: "aesthetic", apns: "computer.aesthetic.easel", web: false, wired: false }, + oskiewar: { tenant: "aesthetic", apns: "computer.aesthetic.oskiewar", web: true, wired: false }, + menuband: { tenant: "aesthetic", apns: "computer.aesthetic.menuband", web: false, wired: false }, + "sotce-net": { tenant: "sotce", apns: null, web: true, wired: false }, +}); + +export const PLATFORMS = Object.freeze(["ios", "ipados", "mac", "web", "windows", "xbox", "linux", "android"]); + +export function appConfig(id) { + return Object.hasOwn(APPS, id) ? APPS[id] : null; +} + +// The stored user key for a verified Auth0 subject in this app's tenant. +export function userKey(app, sub) { + if (!sub) return null; + return appConfig(app)?.tenant === "sotce" ? `sotce-${sub}` : sub; +} diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs index f1b7b75791..530d244cb2 100644 --- a/system/backend/account-deletion.mjs +++ b/system/backend/account-deletion.mjs @@ -340,6 +340,7 @@ const DELETE = [ ["pieces", (sub) => ({ user: sub })], ["moods", (sub) => ({ user: sub })], ["push-tokens", (sub) => ({ user: sub })], + ["app-devices", (sub) => ({ user: { $in: [sub, `sotce-${sub}`] } })], ["easel-transcripts-private", (sub) => ({ owner: sub })], ["walkieware-threads", (sub) => ({ owner: sub })], ["whistlegraph-roblox-rooms", (sub) => ({ _id: sub })], diff --git a/system/netlify/functions/app-device.mjs b/system/netlify/functions/app-device.mjs new file mode 100644 index 0000000000..853941bfe2 --- /dev/null +++ b/system/netlify/functions/app-device.mjs @@ -0,0 +1,85 @@ +// App Device, 2026.10.08 +// The AC network device registry endpoint (shared/app-devices.mjs). +// +// POST /api/app-device {app, deviceId, platform, event, version?, build?, +// model?, os?, label?, push?, topics?} +// Authorization: Bearer is optional. With it the device is bound +// to that account (the app's Auth0 tenant); "logout" unbinds it. +// GET /api/app-device?app=whistlegraph the signed-in person's devices +// DELETE /api/app-device?app=…&deviceId=… remove one of your own devices +import { createHmac, randomBytes } from "node:crypto"; +import { authorize, getHandleOrEmail } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { appConfig, userKey } from "../../../shared/app-registry.mjs"; +import { APP_DEVICES, OWN_DEVICE_FIELDS, deviceKey, normalizeReport, recordReport } from "../../../shared/app-devices.mjs"; + +const HEADERS = { "Cache-Control": "no-store" }; + +// Ephemeral abuse guard, as in app-open: nothing here reaches the database. +const rateSalt = randomBytes(32), rates = new Map(); +function permitted(event) { + const now = Date.now(); + const source = event.headers?.["cf-connecting-ip"] || event.headers?.["x-forwarded-for"] || "unknown"; + const key = createHmac("sha256", rateSalt).update(source).digest("hex"); + for (const [id, row] of rates) if (row.until <= now) rates.delete(id); + let row = rates.get(key); + if (!row) { + if (rates.size >= 10000) return false; + row = { count: 0, until: now + 60000 }; rates.set(key, row); + } + return ++row.count <= 60; +} + +async function identity(event, app) { + if (!event.headers?.authorization) return {}; + const tenant = appConfig(app)?.tenant || "aesthetic"; + const user = await authorize(event.headers, tenant); + if (!user?.sub) throw Object.assign(new Error("That token is not valid."), { statusCode: 401 }); + let handle = null; + if (tenant === "aesthetic") { + const found = await getHandleOrEmail(user.sub).catch(() => null); + if (typeof found === "string" && found.startsWith("@")) handle = found.slice(1); + } + return { user: userKey(app, user.sub), handle }; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, "", HEADERS); + if (!["POST", "GET", "DELETE"].includes(event.httpMethod)) return respond(405, { error: "Method not allowed" }, HEADERS); + if (!permitted(event)) return respond(429, { error: "Rate limit reached" }, HEADERS); + try { + if (event.httpMethod === "POST") { + let body = event.body; + if (typeof body === "string") { + if (body.length > 4096) return respond(400, { error: "Invalid report" }, HEADERS); + body = JSON.parse(body); + } + const report = normalizeReport(body); + const who = await identity(event, report.app); + const { db } = await connect(); + await recordReport(db.collection(APP_DEVICES), report, who); + return respond(204, "", HEADERS); + } + const app = event.queryStringParameters?.app; + if (app !== undefined && !appConfig(app)) return respond(400, { error: "Unknown app" }, HEADERS); + const who = await identity(event, app || "aestheticcomputer"); + if (!who.user) return respond(401, { error: "Sign in to see your devices" }, HEADERS); + const { db } = await connect(); + const devices = db.collection(APP_DEVICES); + if (event.httpMethod === "GET") { + const rows = await devices.find({ user: who.user, ...(app ? { app } : {}) }) + .project(OWN_DEVICE_FIELDS).sort({ lastSeenAt: -1 }).limit(100).toArray(); + return respond(200, { devices: rows }, HEADERS); + } + const deviceId = event.queryStringParameters?.deviceId; + if (!app || typeof deviceId !== "string") return respond(400, { error: "Specify app and deviceId" }, HEADERS); + const result = await devices.deleteOne({ _id: deviceKey(app, deviceId), user: who.user }); + return respond(200, { deleted: result.deletedCount }, HEADERS); + } catch (error) { + if (error instanceof SyntaxError) return respond(400, { error: "Invalid JSON" }, HEADERS); + if (error.statusCode) return respond(error.statusCode, { error: error.message }, HEADERS); + console.error("app-device:", error); + return respond(503, { error: "Device registry unavailable" }, HEADERS); + } +} diff --git a/toolchain/devices.mjs b/toolchain/devices.mjs new file mode 100644 index 0000000000..5dc0aba7bc --- /dev/null +++ b/toolchain/devices.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// devices, 2026.10.08 +// The AC network device registry (Mongo `app-devices`, shared/app-devices.mjs): +// which person runs which build on which device, and who can be notified. +// +// node toolchain/devices.mjs # every app: devices, people, builds +// node toolchain/devices.mjs whistlegraph # one app, newest devices first +// node toolchain/devices.mjs whistlegraph @dreamdeal # one person's devices +// node toolchain/devices.mjs whistlegraph --build 114 +import { MongoClient } from "mongodb"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { APPS } from "../shared/app-registry.mjs"; +import { APP_DEVICES } from "../shared/app-devices.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +// The connection string lives in the vault on a workstation, as in push-subscribers.mjs. +for (const file of ["vault/.devcontainer/envs/devcontainer.env"]) { + const full = path.join(ROOT, file); + if (!fs.existsSync(full)) continue; + for (const line of fs.readFileSync(full, "utf8").split("\n")) { + const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/); + if (match) process.env[match[1]] ??= match[2].trim().replace(/^(["'])(.*)\1$/, "$2"); + } +} +const { MONGODB_CONNECTION_STRING: uri, MONGODB_NAME: name } = process.env; +if (!uri) { console.error("🔴 MONGODB_CONNECTION_STRING missing — is the vault mounted?"); process.exit(1); } + +const args = process.argv.slice(2); +const app = args.find(a => Object.hasOwn(APPS, a)); +const handle = args.find(a => a.startsWith("@"))?.slice(1); +const build = args.includes("--build") ? args[args.indexOf("--build") + 1] : undefined; +const ago = date => { + if (!date) return "—"; + const minutes = Math.round((Date.now() - new Date(date)) / 60000); + return minutes < 60 ? `${minutes}m` : minutes < 2880 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / 1440)}d`; +}; + +const client = new MongoClient(uri); +try { + await client.connect(); + const devices = client.db(name).collection(APP_DEVICES); + if (!app) { + const rows = await devices.aggregate([ + { $group: { _id: "$app", devices: { $sum: 1 }, people: { $addToSet: "$user" }, + pushable: { $sum: { $cond: [{ $ifNull: ["$push", false] }, 1, 0] } }, builds: { $addToSet: "$build" } } }, + { $sort: { devices: -1 } }, + ]).toArray(); + if (!rows.length) console.log("No devices registered yet."); + for (const r of rows) console.log(`${r._id.padEnd(18)} ${String(r.devices).padStart(4)} devices ${String(r.people.filter(Boolean).length).padStart(4)} people ${String(r.pushable).padStart(4)} pushable builds ${r.builds.filter(Boolean).sort((a, b) => b - a).join(",")}`); + } else { + const filter = { app, ...(handle ? { handle } : {}), ...(build ? { build: String(build) } : {}) }; + const rows = await devices.find(filter).sort({ lastSeenAt: -1 }).limit(200).toArray(); + if (!rows.length) console.log("No matching devices."); + for (const d of rows) console.log([ + (d.handle ? "@" + d.handle : "(signed out)").padEnd(18), `build ${d.build || "?"}`.padEnd(10), + `${d.model || "?"} ${d.platform} ${d.os || ""}`.padEnd(28), `opened ${ago(d.lastOpenAt)} ago`.padEnd(16), + `${d.opens || 0} opens`.padEnd(10), d.push ? `push:${d.push.kind}` : "no push", d.deviceId, + ].join(" ")); + } +} finally { + await client.close(); +} -- 2.51.2