Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

tell: one-way user notifications + ios 1.1 (2)

- register-push-token endpoint + ios FCM token bridge (Swift -> WebView -> /api/register-push-token)
- tell endpoint sends push directly to recipient tokens (fast path, not topics); stores in `tells` collection
- `tell @handle text` prompt command patterned after `mood`
- delete-erase-and-forget-me cascades push-tokens + tells
- ios: bump Firebase SPM 10.19 -> 11.15, drop removed FirebaseAnalyticsSwift, bump MARKETING 1.0 -> 1.1 / BUILD 1 -> 2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+518 -142
+9 -17
apple/aesthetic.computer.xcodeproj/project.pbxproj
··· 13 13 41F5CDCE2B2931B000F7FF87 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDCD2B2931B000F7FF87 /* Preview Assets.xcassets */; }; 14 14 41F5CDD62B29333300F7FF87 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */; }; 15 15 41F5CDD82B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */; }; 16 - 41F5CDDA2B29333300F7FF87 /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD92B29333300F7FF87 /* FirebaseAnalyticsSwift */; }; 17 16 41F5CDDC2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */; }; 18 17 41F5CDDE2B29333300F7FF87 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */; }; 19 18 41F5CDE12B29348800F7FF87 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */; }; ··· 73 72 buildActionMask = 2147483647; 74 73 files = ( 75 74 41F5CDE12B29348800F7FF87 /* FirebaseMessaging in Frameworks */, 76 - 41F5CDDA2B29333300F7FF87 /* FirebaseAnalyticsSwift in Frameworks */, 77 75 41F5CDD62B29333300F7FF87 /* FirebaseAnalytics in Frameworks */, 78 76 41F5CDD82B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */, 79 77 41F5CDDE2B29333300F7FF87 /* FirebaseAppCheck in Frameworks */, ··· 174 172 packageProductDependencies = ( 175 173 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */, 176 174 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */, 177 - 41F5CDD92B29333300F7FF87 /* FirebaseAnalyticsSwift */, 178 175 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */, 179 176 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */, 180 177 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */, ··· 429 426 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 430 427 "CODE_SIGN_ENTITLEMENTS[sdk=*]" = aesthetic.computer/aesthetic.computer.entitlements; 431 428 CODE_SIGN_STYLE = Automatic; 432 - CURRENT_PROJECT_VERSION = 1; 429 + CURRENT_PROJECT_VERSION = 2; 433 430 DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; 434 431 DEVELOPMENT_TEAM = FB5948YR3S; 435 432 ENABLE_PREVIEWS = YES; ··· 447 444 "$(inherited)", 448 445 "@executable_path/Frameworks", 449 446 ); 450 - MARKETING_VERSION = 1.0; 447 + MARKETING_VERSION = 1.1; 451 448 PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer; 452 449 PRODUCT_NAME = "$(TARGET_NAME)"; 453 450 SWIFT_EMIT_LOC_STRINGS = YES; ··· 464 461 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 465 462 CODE_SIGN_ENTITLEMENTS = "aesthetic.computer/aesthetic.computer-release.entitlements"; 466 463 CODE_SIGN_STYLE = Automatic; 467 - CURRENT_PROJECT_VERSION = 1; 464 + CURRENT_PROJECT_VERSION = 2; 468 465 DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; 469 466 DEVELOPMENT_TEAM = FB5948YR3S; 470 467 ENABLE_PREVIEWS = YES; ··· 482 479 "$(inherited)", 483 480 "@executable_path/Frameworks", 484 481 ); 485 - MARKETING_VERSION = 1.0; 482 + MARKETING_VERSION = 1.1; 486 483 PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer; 487 484 PRODUCT_NAME = "$(TARGET_NAME)"; 488 485 SWIFT_EMIT_LOC_STRINGS = YES; ··· 496 493 buildSettings = { 497 494 ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; 498 495 CODE_SIGN_STYLE = Automatic; 499 - CURRENT_PROJECT_VERSION = 1; 496 + CURRENT_PROJECT_VERSION = 2; 500 497 DEVELOPMENT_TEAM = FB5948YR3S; 501 498 GENERATE_INFOPLIST_FILE = YES; 502 499 INFOPLIST_FILE = aesthetic/Info.plist; ··· 508 505 "@executable_path/Frameworks", 509 506 "@executable_path/../../Frameworks", 510 507 ); 511 - MARKETING_VERSION = 1.0; 508 + MARKETING_VERSION = 1.1; 512 509 PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer.aesthetic; 513 510 PRODUCT_NAME = "$(TARGET_NAME)"; 514 511 SKIP_INSTALL = YES; ··· 523 520 buildSettings = { 524 521 ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; 525 522 CODE_SIGN_STYLE = Automatic; 526 - CURRENT_PROJECT_VERSION = 1; 523 + CURRENT_PROJECT_VERSION = 2; 527 524 DEVELOPMENT_TEAM = FB5948YR3S; 528 525 GENERATE_INFOPLIST_FILE = YES; 529 526 INFOPLIST_FILE = aesthetic/Info.plist; ··· 535 532 "@executable_path/Frameworks", 536 533 "@executable_path/../../Frameworks", 537 534 ); 538 - MARKETING_VERSION = 1.0; 535 + MARKETING_VERSION = 1.1; 539 536 PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer.aesthetic; 540 537 PRODUCT_NAME = "$(TARGET_NAME)"; 541 538 SKIP_INSTALL = YES; ··· 583 580 repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; 584 581 requirement = { 585 582 kind = upToNextMajorVersion; 586 - minimumVersion = 10.19.0; 583 + minimumVersion = 11.0.0; 587 584 }; 588 585 }; 589 586 /* End XCRemoteSwiftPackageReference section */ ··· 598 595 isa = XCSwiftPackageProductDependency; 599 596 package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 600 597 productName = FirebaseAnalyticsOnDeviceConversion; 601 - }; 602 - 41F5CDD92B29333300F7FF87 /* FirebaseAnalyticsSwift */ = { 603 - isa = XCSwiftPackageProductDependency; 604 - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 605 - productName = FirebaseAnalyticsSwift; 606 598 }; 607 599 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */ = { 608 600 isa = XCSwiftPackageProductDependency;
+132 -122
apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
··· 1 - { 2 - "pins" : [ 3 - { 4 - "identity" : "abseil-cpp-binary", 5 - "kind" : "remoteSourceControl", 6 - "location" : "https://github.com/google/abseil-cpp-binary.git", 7 - "state" : { 8 - "revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c", 9 - "version" : "1.2022062300.0" 10 - } 11 - }, 12 - { 13 - "identity" : "app-check", 14 - "kind" : "remoteSourceControl", 15 - "location" : "https://github.com/google/app-check.git", 16 - "state" : { 17 - "revision" : "5746b2d35c91c50581590ed97abe4c06b5037274", 18 - "version" : "10.18.0" 19 - } 20 - }, 21 - { 22 - "identity" : "firebase-ios-sdk", 23 - "kind" : "remoteSourceControl", 24 - "location" : "https://github.com/firebase/firebase-ios-sdk", 25 - "state" : { 26 - "revision" : "d9bcd141c3e4ad48a9500e6faeebb073f43cbcbd", 27 - "version" : "10.19.0" 28 - } 29 - }, 30 - { 31 - "identity" : "googleappmeasurement", 32 - "kind" : "remoteSourceControl", 33 - "location" : "https://github.com/google/GoogleAppMeasurement.git", 34 - "state" : { 35 - "revision" : "6b332152355c372ace9966d8ee76ed191f97025e", 36 - "version" : "10.17.0" 37 - } 38 - }, 39 - { 40 - "identity" : "googledatatransport", 41 - "kind" : "remoteSourceControl", 42 - "location" : "https://github.com/google/GoogleDataTransport.git", 43 - "state" : { 44 - "revision" : "a732a4b47f59e4f725a2ea10f0c77e93a7131117", 45 - "version" : "9.3.0" 46 - } 47 - }, 48 - { 49 - "identity" : "googleutilities", 50 - "kind" : "remoteSourceControl", 51 - "location" : "https://github.com/google/GoogleUtilities.git", 52 - "state" : { 53 - "revision" : "bc27fad73504f3d4af235de451f02ee22586ebd3", 54 - "version" : "7.12.1" 55 - } 56 - }, 57 - { 58 - "identity" : "grpc-binary", 59 - "kind" : "remoteSourceControl", 60 - "location" : "https://github.com/google/grpc-binary.git", 61 - "state" : { 62 - "revision" : "a673bc2937fbe886dd1f99c401b01b6d977a9c98", 63 - "version" : "1.49.1" 64 - } 65 - }, 66 - { 67 - "identity" : "gtm-session-fetcher", 68 - "kind" : "remoteSourceControl", 69 - "location" : "https://github.com/google/gtm-session-fetcher.git", 70 - "state" : { 71 - "revision" : "115f75e43851774934d695449a4836123c3246e1", 72 - "version" : "3.2.0" 73 - } 74 - }, 75 - { 76 - "identity" : "interop-ios-for-google-sdks", 77 - "kind" : "remoteSourceControl", 78 - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", 79 - "state" : { 80 - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", 81 - "version" : "100.0.0" 82 - } 83 - }, 84 - { 85 - "identity" : "leveldb", 86 - "kind" : "remoteSourceControl", 87 - "location" : "https://github.com/firebase/leveldb.git", 88 - "state" : { 89 - "revision" : "9d108e9112aa1d65ce508facf804674546116d9c", 90 - "version" : "1.22.3" 91 - } 92 - }, 93 - { 94 - "identity" : "nanopb", 95 - "kind" : "remoteSourceControl", 96 - "location" : "https://github.com/firebase/nanopb.git", 97 - "state" : { 98 - "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692", 99 - "version" : "2.30909.0" 100 - } 101 - }, 102 - { 103 - "identity" : "promises", 104 - "kind" : "remoteSourceControl", 105 - "location" : "https://github.com/google/promises.git", 106 - "state" : { 107 - "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e", 108 - "version" : "2.3.1" 109 - } 110 - }, 111 - { 112 - "identity" : "swift-protobuf", 113 - "kind" : "remoteSourceControl", 114 - "location" : "https://github.com/apple/swift-protobuf.git", 115 - "state" : { 116 - "revision" : "65e8f29b2d63c4e38e736b25c27b83e012159be8", 117 - "version" : "1.25.2" 118 - } 119 - } 120 - ], 121 - "version" : 2 122 - } 1 + { 2 + "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", 3 + "pins" : [ 4 + { 5 + "identity" : "abseil-cpp-binary", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/google/abseil-cpp-binary.git", 8 + "state" : { 9 + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", 10 + "version" : "1.2024072200.0" 11 + } 12 + }, 13 + { 14 + "identity" : "app-check", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/google/app-check.git", 17 + "state" : { 18 + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", 19 + "version" : "11.2.0" 20 + } 21 + }, 22 + { 23 + "identity" : "firebase-ios-sdk", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/firebase/firebase-ios-sdk", 26 + "state" : { 27 + "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", 28 + "version" : "11.15.0" 29 + } 30 + }, 31 + { 32 + "identity" : "google-ads-on-device-conversion-ios-sdk", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", 35 + "state" : { 36 + "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", 37 + "version" : "2.3.0" 38 + } 39 + }, 40 + { 41 + "identity" : "googleappmeasurement", 42 + "kind" : "remoteSourceControl", 43 + "location" : "https://github.com/google/GoogleAppMeasurement.git", 44 + "state" : { 45 + "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", 46 + "version" : "11.15.0" 47 + } 48 + }, 49 + { 50 + "identity" : "googledatatransport", 51 + "kind" : "remoteSourceControl", 52 + "location" : "https://github.com/google/GoogleDataTransport.git", 53 + "state" : { 54 + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", 55 + "version" : "10.1.0" 56 + } 57 + }, 58 + { 59 + "identity" : "googleutilities", 60 + "kind" : "remoteSourceControl", 61 + "location" : "https://github.com/google/GoogleUtilities.git", 62 + "state" : { 63 + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", 64 + "version" : "8.1.0" 65 + } 66 + }, 67 + { 68 + "identity" : "grpc-binary", 69 + "kind" : "remoteSourceControl", 70 + "location" : "https://github.com/google/grpc-binary.git", 71 + "state" : { 72 + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", 73 + "version" : "1.69.1" 74 + } 75 + }, 76 + { 77 + "identity" : "gtm-session-fetcher", 78 + "kind" : "remoteSourceControl", 79 + "location" : "https://github.com/google/gtm-session-fetcher.git", 80 + "state" : { 81 + "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", 82 + "version" : "4.5.0" 83 + } 84 + }, 85 + { 86 + "identity" : "interop-ios-for-google-sdks", 87 + "kind" : "remoteSourceControl", 88 + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", 89 + "state" : { 90 + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", 91 + "version" : "101.0.0" 92 + } 93 + }, 94 + { 95 + "identity" : "leveldb", 96 + "kind" : "remoteSourceControl", 97 + "location" : "https://github.com/firebase/leveldb.git", 98 + "state" : { 99 + "revision" : "9d108e9112aa1d65ce508facf804674546116d9c", 100 + "version" : "1.22.3" 101 + } 102 + }, 103 + { 104 + "identity" : "nanopb", 105 + "kind" : "remoteSourceControl", 106 + "location" : "https://github.com/firebase/nanopb.git", 107 + "state" : { 108 + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", 109 + "version" : "2.30910.0" 110 + } 111 + }, 112 + { 113 + "identity" : "promises", 114 + "kind" : "remoteSourceControl", 115 + "location" : "https://github.com/google/promises.git", 116 + "state" : { 117 + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", 118 + "version" : "2.4.0" 119 + } 120 + }, 121 + { 122 + "identity" : "swift-protobuf", 123 + "kind" : "remoteSourceControl", 124 + "location" : "https://github.com/apple/swift-protobuf.git", 125 + "state" : { 126 + "revision" : "65e8f29b2d63c4e38e736b25c27b83e012159be8", 127 + "version" : "1.25.2" 128 + } 129 + } 130 + ], 131 + "version" : 3 132 + }
+36 -2
apple/aesthetic.computer/aesthetic_computerApp.swift
··· 182 182 object: nil, 183 183 userInfo: dataDict 184 184 ) 185 - // TODO: If necessary, send token to your application server. 186 - // Note: This callback is fired at each app startup and whenever a new token is generated. 185 + // Hand the token to the WebView so the AC runtime can POST it to 186 + // /api/register-push-token against the logged-in user. 187 + // This callback is fired at each app startup and whenever a new token is generated. 188 + guard let token = fcmToken, !token.isEmpty else { return } 189 + deliverPushTokenToWebView(token) 190 + } 191 + 192 + private func deliverPushTokenToWebView(_ token: String, attempt: Int = 0) { 193 + // Retry a few times so we don't lose the token if the WebView hasn't 194 + // finished loading AC's bios.mjs yet (iOSReceivePushToken lives there). 195 + let escaped = token.replacingOccurrences(of: "\\", with: "\\\\") 196 + .replacingOccurrences(of: "'", with: "\\'") 197 + let script = "window.iOSReceivePushToken && window.iOSReceivePushToken('\(escaped)', 'ios');" 198 + DispatchQueue.main.async { [weak self] in 199 + guard let self = self else { return } 200 + guard let webView = self.appWebView else { 201 + if attempt < 20 { 202 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 203 + self.deliverPushTokenToWebView(token, attempt: attempt + 1) 204 + } 205 + } 206 + return 207 + } 208 + webView.evaluateJavaScript(script) { _, error in 209 + if let error = error { 210 + print("📱 🔔 Failed to hand token to WebView: \(error)") 211 + if attempt < 20 { 212 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 213 + self.deliverPushTokenToWebView(token, attempt: attempt + 1) 214 + } 215 + } 216 + } else { 217 + print("📱 🔔 Handed FCM token to WebView (attempt \(attempt)).") 218 + } 219 + } 220 + } 187 221 } 188 222 }
+8
system/netlify/functions/delete-erase-and-forget-me.mjs
··· 104 104 await database.db.collection("moods").deleteMany({ user: sub }); 105 105 console.log("🧠 Deleted moods."); 106 106 107 + await database.db.collection("push-tokens").deleteMany({ user: sub }); 108 + console.log("🔔 Deleted push tokens."); 109 + 110 + await database.db 111 + .collection("tells") 112 + .deleteMany({ $or: [{ to: sub }, { from: sub }] }); 113 + console.log("📣 Deleted tells (sent + received)."); 114 + 107 115 await database.db.collection("verifications").deleteOne({ _id: sub }); 108 116 console.log("🧏 Deleted verification count."); 109 117
+66
system/netlify/functions/register-push-token.mjs
··· 1 + // register-push-token, 26.04.23 2 + // Stores a device's push-notification token against the logged-in user so the 3 + // backend can deliver targeted notifications (fast, per-device) instead of 4 + // topic broadcasts. Used by the iOS app (FCM) and eventually web/android. 5 + 6 + // POST /api/register-push-token 7 + // body: { token: string, platform: "ios" | "android" | "web", remove?: true } 8 + // headers: Authorization: Bearer <Auth0 token> 9 + // behavior: 10 + // - remove=true → delete this (user, token) pair (logout) 11 + // - otherwise → upsert { user: sub, token, platform, updatedAt } 12 + // and delete any other user's binding to the same token 13 + // (account-switch on the same device). 14 + 15 + import { authorize } from "../../backend/authorization.mjs"; 16 + import { connect } from "../../backend/database.mjs"; 17 + import { respond } from "../../backend/http.mjs"; 18 + 19 + export async function handler(event) { 20 + if (event.httpMethod !== "POST") { 21 + return respond(405, { message: "Method Not Allowed" }); 22 + } 23 + 24 + let body; 25 + try { 26 + body = JSON.parse(event.body || "{}"); 27 + } catch { 28 + return respond(400, { message: "Invalid JSON body" }); 29 + } 30 + 31 + const { token, platform, remove } = body; 32 + if (typeof token !== "string" || token.length < 16 || token.length > 4096) { 33 + return respond(400, { message: "Invalid token" }); 34 + } 35 + if (!remove && !["ios", "android", "web"].includes(platform)) { 36 + return respond(400, { message: "Invalid platform" }); 37 + } 38 + 39 + const user = await authorize(event.headers); 40 + if (!user?.sub) return respond(401, { message: "Unauthorized" }); 41 + 42 + const database = await connect(); 43 + try { 44 + const collection = database.db.collection("push-tokens"); 45 + await collection.createIndex({ user: 1, token: 1 }, { unique: true }); 46 + await collection.createIndex({ token: 1 }); 47 + 48 + if (remove) { 49 + const result = await collection.deleteOne({ user: user.sub, token }); 50 + return respond(200, { status: "removed", deleted: result.deletedCount }); 51 + } 52 + 53 + await collection.deleteMany({ token, user: { $ne: user.sub } }); 54 + await collection.updateOne( 55 + { user: user.sub, token }, 56 + { $set: { user: user.sub, token, platform, updatedAt: new Date() } }, 57 + { upsert: true }, 58 + ); 59 + return respond(200, { status: "registered" }); 60 + } catch (err) { 61 + console.error("🔴 register-push-token error:", err); 62 + return respond(500, { message: err?.message || "Server error" }); 63 + } finally { 64 + await database.disconnect(); 65 + } 66 + }
+156
system/netlify/functions/tell.mjs
··· 1 + // tell, 26.04.23 2 + // Send a one-way "tell" from one AC user to another. The recipient gets a 3 + // push notification on every registered device and the message is stored 4 + // in the `tells` collection as their inbox. 5 + // 6 + // POST /api/tell 7 + // body: { to: "@handle", text: "message" } 8 + // headers: Authorization: Bearer <Auth0 token> 9 + 10 + import { 11 + authorize, 12 + userIDFromHandleOrEmail, 13 + getHandleOrEmail, 14 + } from "../../backend/authorization.mjs"; 15 + import { connect } from "../../backend/database.mjs"; 16 + import { respond } from "../../backend/http.mjs"; 17 + import { filter } from "../../backend/filter.mjs"; 18 + import { shell } from "../../backend/shell.mjs"; 19 + 20 + import { initializeApp, cert, getApps } from "firebase-admin/app"; 21 + import { getMessaging } from "firebase-admin/messaging"; 22 + 23 + const MAX_TEXT_LENGTH = 500; 24 + 25 + export async function handler(event) { 26 + if (event.httpMethod !== "POST") { 27 + return respond(405, { message: "Method Not Allowed" }); 28 + } 29 + 30 + let body; 31 + try { 32 + body = JSON.parse(event.body || "{}"); 33 + } catch { 34 + return respond(400, { message: "Invalid JSON body" }); 35 + } 36 + 37 + const rawTo = typeof body.to === "string" ? body.to.trim() : ""; 38 + const rawText = typeof body.text === "string" ? body.text : ""; 39 + if (!rawTo) return respond(400, { message: "Missing recipient" }); 40 + const text = filter(rawText.trim()).slice(0, MAX_TEXT_LENGTH); 41 + if (!text) return respond(400, { message: "Empty message" }); 42 + 43 + const sender = await authorize(event.headers); 44 + if (!sender?.sub) return respond(401, { message: "Unauthorized" }); 45 + 46 + const database = await connect(); 47 + try { 48 + const recipientSub = await userIDFromHandleOrEmail(rawTo, database); 49 + if (!recipientSub) { 50 + return respond(404, { message: "Recipient not found" }); 51 + } 52 + 53 + const fromHandle = await getHandleOrEmail(sender.sub); 54 + const toHandle = rawTo.startsWith("@") ? rawTo : `@${rawTo}`; 55 + 56 + const tells = database.db.collection("tells"); 57 + await tells.createIndex({ to: 1, when: -1 }); 58 + await tells.createIndex({ from: 1, when: -1 }); 59 + 60 + const when = new Date(); 61 + const insertResult = await tells.insertOne({ 62 + to: recipientSub, 63 + toHandle, 64 + from: sender.sub, 65 + fromHandle, 66 + text, 67 + when, 68 + }); 69 + 70 + // Look up recipient's registered push tokens. 71 + const pushTokens = await database.db 72 + .collection("push-tokens") 73 + .find({ user: recipientSub }) 74 + .project({ _id: 0, token: 1 }) 75 + .toArray(); 76 + const tokens = pushTokens.map((d) => d.token).filter(Boolean); 77 + 78 + let pushSummary = { attempted: 0, succeeded: 0, failed: 0 }; 79 + 80 + if (tokens.length > 0) { 81 + const { got } = await import("got"); 82 + const serviceAccount = ( 83 + await got(process.env.GCM_FIREBASE_CONFIG_URL, { responseType: "json" }) 84 + ).body; 85 + if (getApps().length === 0) { 86 + initializeApp({ credential: cert(serviceAccount) }); 87 + } 88 + 89 + const message = { 90 + notification: { 91 + title: `${fromHandle} told you`, 92 + body: text, 93 + }, 94 + data: { 95 + kind: "tell", 96 + from: fromHandle || "", 97 + tellId: insertResult.insertedId.toString(), 98 + }, 99 + apns: { 100 + payload: { aps: { sound: "default", "mutable-content": 1 } }, 101 + }, 102 + }; 103 + 104 + try { 105 + const response = await getMessaging().sendEachForMulticast({ 106 + tokens, 107 + ...message, 108 + }); 109 + pushSummary = { 110 + attempted: tokens.length, 111 + succeeded: response.successCount, 112 + failed: response.failureCount, 113 + }; 114 + 115 + const invalid = []; 116 + response.responses.forEach((resp, i) => { 117 + if (resp.success) return; 118 + const code = resp.error?.code || ""; 119 + if ( 120 + code === "messaging/registration-token-not-registered" || 121 + code === "messaging/invalid-registration-token" || 122 + code === "messaging/invalid-argument" 123 + ) { 124 + invalid.push(tokens[i]); 125 + } else { 126 + shell.log( 127 + `⚠️ tell push error for ${recipientSub}:`, 128 + code, 129 + resp.error?.message, 130 + ); 131 + } 132 + }); 133 + if (invalid.length) { 134 + await database.db 135 + .collection("push-tokens") 136 + .deleteMany({ token: { $in: invalid } }); 137 + shell.log(`🧹 Pruned ${invalid.length} stale push tokens.`); 138 + } 139 + } catch (err) { 140 + shell.log("🔴 tell push send failed:", err?.message || err); 141 + } 142 + } 143 + 144 + return respond(200, { 145 + status: "told", 146 + to: toHandle, 147 + when, 148 + push: pushSummary, 149 + }); 150 + } catch (err) { 151 + console.error("🔴 tell error:", err); 152 + return respond(500, { message: err?.message || "Server error" }); 153 + } finally { 154 + await database.disconnect(); 155 + } 156 + }
+69
system/public/aesthetic.computer/bios.mjs
··· 21270 21270 content: { piece, ahistorical: false, alias: false }, 21271 21271 }); 21272 21272 }; 21273 + 21274 + // 🔔 Native push-token bridge. 21275 + // Swift calls window.iOSReceivePushToken when FCM hands it a token. 21276 + // If the user is logged in we POST it to /api/register-push-token right 21277 + // away; otherwise we stash it and register once session:started fires 21278 + // (see iOSTryRegisterPushToken, called from boot.mjs after login). 21279 + let _iosPushToken = null; 21280 + let _iosPushPlatform = "ios"; 21281 + let _iosPushRegistered = false; 21282 + let _iosPushInflight = false; 21283 + 21284 + window.iOSReceivePushToken = (token, platform) => { 21285 + if (typeof token !== "string" || !token) return; 21286 + if (_iosPushToken === token && _iosPushRegistered) return; 21287 + _iosPushToken = token; 21288 + _iosPushPlatform = platform || "ios"; 21289 + _iosPushRegistered = false; 21290 + window.iOSTryRegisterPushToken(); 21291 + }; 21292 + 21293 + window.iOSTryRegisterPushToken = async () => { 21294 + if (!_iosPushToken || _iosPushRegistered || _iosPushInflight) return; 21295 + if (!window.auth0Client || !window.acUSER) return; // wait for login 21296 + _iosPushInflight = true; 21297 + try { 21298 + const authToken = await window.auth0Client.getTokenSilently(); 21299 + if (!authToken) return; 21300 + const res = await fetch("/api/register-push-token", { 21301 + method: "POST", 21302 + headers: { 21303 + "Content-Type": "application/json", 21304 + Authorization: `Bearer ${authToken}`, 21305 + }, 21306 + body: JSON.stringify({ 21307 + token: _iosPushToken, 21308 + platform: _iosPushPlatform, 21309 + }), 21310 + }); 21311 + if (res.ok) { 21312 + _iosPushRegistered = true; 21313 + console.log("📱 🔔 Push token registered."); 21314 + } else { 21315 + console.warn("📱 🔔 Push token registration failed:", res.status); 21316 + } 21317 + } catch (err) { 21318 + console.warn("📱 🔔 Push token registration error:", err); 21319 + } finally { 21320 + _iosPushInflight = false; 21321 + } 21322 + }; 21323 + 21324 + window.iOSUnregisterPushToken = async () => { 21325 + if (!_iosPushToken || !window.auth0Client || !window.acUSER) return; 21326 + try { 21327 + const authToken = await window.auth0Client.getTokenSilently(); 21328 + if (!authToken) return; 21329 + await fetch("/api/register-push-token", { 21330 + method: "POST", 21331 + headers: { 21332 + "Content-Type": "application/json", 21333 + Authorization: `Bearer ${authToken}`, 21334 + }, 21335 + body: JSON.stringify({ token: _iosPushToken, remove: true }), 21336 + }); 21337 + _iosPushRegistered = false; 21338 + } catch (err) { 21339 + console.warn("📱 🔔 Push token unregister error:", err); 21340 + } 21341 + }; 21273 21342 } // End of boot function 21274 21343 21275 21344 function iOSAppSend(message) {
+7 -1
system/public/aesthetic.computer/boot.mjs
··· 1505 1505 const legitParams = extractLegitimateParams(window.location.href); 1506 1506 if (legitParams.has("signup")) window.acLOGIN("signup"); 1507 1507 1508 - window.acLOGOUT = () => { 1508 + window.acLOGOUT = async () => { 1509 1509 if (isAuthenticated) { 1510 1510 console.log("🔐 Logging out..."); 1511 1511 window.acSEND({ 1512 1512 type: "logout:broadcast:subscribe", 1513 1513 content: { user: window.acUSER }, 1514 1514 }); 1515 + try { 1516 + await window.iOSUnregisterPushToken?.(); 1517 + } catch {} 1515 1518 auth0Client.logout({ 1516 1519 logoutParams: { returnTo: window.location.origin }, 1517 1520 }); ··· 1559 1562 type: "session:started", 1560 1563 content: { user: window.acUSER }, 1561 1564 }); 1565 + 1566 + // 🔔 If the native iOS app handed us a push token before login, register it now. 1567 + window.iOSTryRegisterPushToken?.(); 1562 1568 1563 1569 // Background: fetch handle from /user and refresh token if needed. 1564 1570 // This runs after the disk already has auth — it just enriches data.
+35
system/public/aesthetic.computer/disks/prompt.mjs
··· 2350 2350 } 2351 2351 makeFlash($); 2352 2352 return true; 2353 + } else if (slug === "tell") { 2354 + // tell @handle your one-way message 2355 + const to = params[0] || ""; 2356 + const body = params.slice(1).join(" ").trim(); 2357 + if (!to.startsWith("@")) { 2358 + flashColor = [255, 255, 0]; 2359 + notice("TELL WHO?", ["yellow", "red"]); 2360 + makeFlash($, true); 2361 + return true; 2362 + } 2363 + if (!body) { 2364 + flashColor = [255, 255, 0]; 2365 + notice("TELL WHAT?", ["yellow", "red"]); 2366 + makeFlash($, true); 2367 + return true; 2368 + } 2369 + const res = await net.userRequest("POST", "/api/tell", { to, text: body }); 2370 + if (res?.status === 200) { 2371 + flashColor = [0, 255, 0]; 2372 + notice(`TOLD ${to.toUpperCase()}`); 2373 + } else if (res?.status === 404) { 2374 + flashColor = [255, 0, 0]; 2375 + notice("NO HANDLE", ["yellow", "red"]); 2376 + makeFlash($, true); 2377 + } else if (res?.status === 401 || res?.message === "unauthorized") { 2378 + flashColor = [255, 0, 0]; 2379 + notice("LOG IN TO TELL", ["yellow", "red"]); 2380 + makeFlash($, true); 2381 + } else { 2382 + flashColor = [255, 0, 0]; 2383 + notice("TELL FAILED", ["yellow", "red"]); 2384 + makeFlash($, true); 2385 + } 2386 + makeFlash($); 2387 + return true; 2353 2388 } else if (text.startsWith("publish")) { 2354 2389 const publishablePiece = store["publishable-piece"]; 2355 2390 if (!publishablePiece) {