= nil, visibility : SessionVisibility = SessionVisibility::Public, list_ids : Array(String) = [] of String) : Session
   259→   259→    id = generate_id
   260→   260→    session = Session.new(id, owner_id, visibility, list_ids)
   261→   261→    @mutex.synchronize { @sessions[id] = session }
   262→   262→    session
   263→   263→  end
   264→   264→
   265→   265→  def get(id : String) : Session?
   266→   266→    @mutex.synchronize { @sessions[id]? }
   267→   267→  end
   268→   268→
   269→   269→  def delete(id : String)
   270→   270→    @mutex.synchronize do
   271→   271→      if session = @sessions.delete(id)
   272→   272→        session.set_inactive
   273→   273→      end
   274→   274→    end
   275→   275→  end
   276→   276→
   277→   277→  def find_by_owner(owner_id : String) : Array(Session)
   278→   278→    @mutex.synchronize do
   279→   279→      @sessions.values.select { |s| s.owner_id == owner_id && s.active }
   280→   280→    end
   281→   281→  end
   282→   282→
   283→   283→  private def generate_id : String
   284→   284→    bytes = Random::Secure.random_bytes(6)
   285→   285→    part1 = bytes[0, 3].hexstring[0, 4]
   286→   286→    part2 = bytes[3, 3].hexstring[0, 4]
   287→   287→    "#{part1}-#{part2}"
   288→   288→  end
   289→   289→
   290→   290→  private def start_cleanup
   291→   291→    spawn do
   292→   292→      loop do
   293→   293→        sleep 10.minutes
   294→   294→        @mutex.synchronize do
   295→   295→          @sessions.reject! { |_, s| s.expired? }
   296→   296→        end
   297→   297→      end
   298→   298→    end
   299→   299→  end
   300→   300→end
   301→   301→
   302→   302→SESSIONS = SessionStore.new
   303→   303→
   304→   304→# Friend location tracking (in-memory, keyed by local_actor_id -> remote_actor_url -> location)
   305→   305→struct FriendLocation
   306→   306→  include JSON::Serializable
   307→   307→
   308→   308→  property remote_actor_url : String
   309→   309→  property remote_actor_acct : String
   310→   310→  property lat : Float64
   311→   311→  property lng : Float64
   312→   312→  property accuracy : Float64?
   313→   313→  property speed : Float64?
   314→   314→  property heading : Float64?
   315→   315→  property updated_at : Time
   316→   316→
   317→   317→  def initialize(
   318→   318→    @remote_actor_url : String,
   319→   319→    @remote_actor_acct : String,
   320→   320→    @lat : Float64,
   321→   321→    @lng : Float64,
   322→   322→    @accuracy : Float64? = nil,
   323→   323→    @speed : Float64? = nil,
   324→   324→    @heading : Float64? = nil,
   325→   325→    @updated_at : Time = Time.utc
   326→   326→  )
   327→   327→  end
   328→   328→
   329→   329→  def expired? : Bool
   330→   330→    (Time.utc - @updated_at) > 30.minutes
   331→   331→  end
   332→   332→end
   333→   333→
   334→   334→class FriendLocationsStore
   335→   335→  def initialize
   336→   336→    # local_actor_id => { remote_actor_url => FriendLocation }
   337→   337→    @locations = {} of String => Hash(String, FriendLocation)
   338→   338→    @mutex = Mutex.new
   339→   339→    start_cleanup
   340→   340→  end
   341→   341→
   342→   342→  def update(local_actor_id : String, location : FriendLocation)
   343→   343→    @mutex.synchronize do
   344→   344→      @locations[local_actor_id] ||= {} of String => FriendLocation
   345→   345→      @locations[local_actor_id][location.remote_actor_url] = location
   346→   346→    end
   347→   347→  end
   348→   348→
   349→   349→  def get_all(local_actor_id : String) : Array(FriendLocation)
   350→   350→    @mutex.synchronize do
   351→   351→      if locs = @locations[local_actor_id]?
   352→   352→        locs.values.reject(&.expired?)
   353→   353→      else
   354→   354→        [] of FriendLocation
   355→   355→      end
   356→   356→    end
   357→   357→  end
   358→   358→
   359→   359→  def remove(local_actor_id : String, remote_actor_url : String)
   360→   360→    @mutex.synchronize do
   361→   361→      if locs = @locations[local_actor_id]?
   362→   362→        locs.delete(remote_actor_url)
   363→   363→      end
   364→   364→    end
   365→   365→  end
   366→   366→
   367→   367→  private def start_cleanup
   368→   368→    spawn do
   369→   369→      loop do
   370→   370→        sleep 5.minutes
   371→   371→        @mutex.synchronize do
   372→   372→          @locations.each do |_, locs|
   373→   373→            locs.reject! { |_, loc| loc.expired? }
   374→   374→          end
   375→   375→          @locations.reject! { |_, locs| locs.empty? }
   376→   376→        end
   377→   377→      end
   378→   378→    end
   379→   379→  end
   380→   380→end
   381→   381→
   382→   382→FRIEND_LOCATIONS = FriendLocationsStore.new
   383→   383→
   384→   384→# Auth helpers
   385→   385→
   386→   386→struct AuthResult
   387→   387→  property authenticated : Bool
   388→   388→  property actor : Actor?
   389→   389→
   390→   390→  def initialize(@authenticated, @actor = nil)
   391→   391→  end
   392→   392→end
   393→   393→
   394→   394→def check_auth(context) : AuthResult
   395→   395→  auth_header = context.request.headers["Authorization"]?
   396→   396→
   397→   397→  if auth_header && auth_header.starts_with?("Bearer ")
   398→   398→    token = auth_header[7..]
   399→   399→
   400→   400→    if actor = ACTOR_STORE.find_by_token(token)
   401→   401→      return AuthResult.new(authenticated: true, actor: actor)
   402→   402→    end
   403→   403→  end
   404→   404→
   405→   405→  AuthResult.new(authenticated: false)
   406→   406→end
   407→   407→
   408→   408→def check_http_signature(context) : Actor?
   409→   409→  key_id = HTTPSignature.extract_key_id(context.request)
   410→   410→  return nil unless key_id
   411→   411→
   412→   412→  # Try cache first
   413→   413→  pem = ACTOR_STORE.get_cached_key(key_id)
   414→   414→
   415→   415→  unless pem
   416→   416→    result = Federation.fetch_public_key(key_id)
   417→   417→    return nil unless result
   418→   418→    actor_id, pem = result
   419→   419→    ACTOR_STORE.cache_remote_key(key_id, actor_id, pem)
   420→   420→  end
   421→   421→
   422→   422→  public_key = OpenSSL::PKey::RSA.new(pem)
   423→   423→  return nil unless HTTPSignature.verify(context.request, public_key)
   424→   424→
   425→   425→  actor_url = key_id.split("#").first
   426→   426→  actor = ACTOR_STORE.find_by_id(actor_url)
   427→   427→  return actor if actor
   428→   428→
   429→   429→  # Fetch and cache remote actor
   430→   430→  remote_actor = Federation.fetch_actor(actor_url)
   431→   431→  return nil unless remote_actor
   432→   432→
   433→   433→  ACTOR_STORE.upsert_remote(remote_actor)
   434→   434→end
   435→   435→
   436→   436→def cors_headers(context, origin = "*")
   437→   437→  context.response.headers["Access-Control-Allow-Origin"] = origin
   438→   438→  context.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
   439→   439→  context.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Accept, Signature, Date, Digest"
   440→   440→  context.response.headers["Access-Control-Allow-Credentials"] = "true"
   441→   441→end
   442→   442→
   443→   443→def extract_session_id(path : String) : String?
   444→   444→  if match = path.match(/^\/api\/session\/([a-f0-9]{4}-[a-f0-9]{4})/)
   445→   445→    match[1]
   446→   446→  else
   447→   447→    nil
   448→   448→  end
   449→   449→end
   450→   450→
   451→   451→def extract_username(path : String) : String?
   452→   452→  if match = path.match(/^\/users\/([a-zA-Z0-9_-]+)/)
   453→   453→    match[1]
   454→   454→  else
   455→   455→    nil
   456→   456→  end
   457→   457→end
   458→   458→
   459→   459→def wants_activitypub?(context) : Bool
   460→   460→  accept = context.request.headers["Accept"]? || ""
   461→   461→  accept.includes?("application/activity+json") ||
   462→   462→    accept.includes?("application/ld+json")
   463→   463→end
   464→   464→
   465→   465→# HTTP Server
   466→   466→
   467→   467→server = HTTP::Server.new do |context|
   468→   468→  origin = context.request.headers["Origin"]? || "*"
   469→   469→  cors_headers(context, origin)
   470→   470→
   471→   471→  if context.request.method == "OPTIONS"
   472→   472→    context.response.status = HTTP::Status::OK
   473→   473→    next
   474→   474→  end
   475→   475→
   476→   476→  path = context.request.path
   477→   477→  method = context.request.method
   478→   478→
   479→   479→  case {path, method}
   480→   480→  # ============================================
   481→   481→  # WebFinger
   482→   482→  # ============================================
   483→   483→  when {"/.well-known/webfinger", "GET"}
   484→   484→    resource = context.request.query_params["resource"]?
   485→   485→    unless resource
   486→   486→      context.response.status = HTTP::Status::BAD_REQUEST
   487→   487→      context.response.print(%({"error": "Missing resource parameter"}))
   488→   488→      next
   489→   489→    end
   490→   490→
   491→   491→    # Parse acct:username@domain
   492→   492→    acct = resource.sub(/^acct:/, "")
   493→   493→    parts = acct.split("@")
   494→   494→    unless parts.size == 2 && parts[1] == DOMAIN
   495→   495→      context.response.status = HTTP::Status::NOT_FOUND
   496→   496→      context.response.print(%({"error": "User not found"}))
   497→   497→      next
   498→   498→    end
   499→   499→
   500→   500→    username = parts[0]
   501→   501→    actor = ACTOR_STORE.find_by_username(username)
   502→   502→    unless actor
   503→   503→      context.response.status = HTTP::Status::NOT_FOUND
   504→   504→      context.response.print(%({"error": "User not found"}))
   505→   505→      next
   506→   506→    end
   507→   507→
   508→   508→    context.response.content_type = CT_JRD
   509→   509→    context.response.print(JSON.build do |json|
   510→   510→      json.object do
   511→   511→        json.field "subject", "acct:#{actor.acct}"
   512→   512→        json.field "aliases", [actor.id]
   513→   513→        json.field "links" do
   514→   514→          json.array do
   515→   515→            json.object do
   516→   516→              json.field "rel", "self"
   517→   517→              json.field "type", CT_ACTIVITYPUB
   518→   518→              json.field "href", actor.id
   519→   519→            end
   520→   520→            json.object do
   521→   521→              json.field "rel", "http://webfinger.net/rel/profile-page"
   522→   522→              json.field "type", "text/html"
   523→   523→              json.field "href", actor.id
   524→   524→            end
   525→   525→          end
   526→   526→        end
   527→   527→      end
   528→   528→    end)
   529→   529→
   530→   530→    # ============================================
   531→   531→    # NodeInfo
   532→   532→    # ============================================
   533→   533→  when {"/.well-known/nodeinfo", "GET"}
   534→   534→    context.response.content_type = CT_JSON
   535→   535→    context.response.print(JSON.build do |json|
   536→   536→      json.object do
   537→   537→        json.field "links" do
   538→   538→          json.array do
   539→   539→            json.object do
   540→   540→              json.field "rel", "http://nodeinfo.diaspora.software/ns/schema/2.1"
   541→   541→              json.field "href", "https://#{DOMAIN}/nodeinfo/2.1"
   542→   542→            end
   543→   543→          end
   544→   544→        end
   545→   545→      end
   546→   546→    end)
   547→   547→  when {"/nodeinfo/2.1", "GET"}
   548→   548→    local_users = ACTOR_STORE.find_local_actors.size
   549→   549→    context.response.content_type = CT_JSON
   550→   550→    context.response.print(JSON.build do |json|
   551→   551→      json.object do
   552→   552→        json.field "version", "2.1"
   553→   553→        json.field "software" do
   554→   554→          json.object do
   555→   555→            json.field "name", "josie.place"
   556→   556→            json.field "version", "0.2.0"
   557→   557→            json.field "repository", "https://github.com/josie/place"
   558→   558→          end
   559→   559→        end
   560→   560→        json.field "protocols", ["activitypub"]
   561→   561→        json.field "services" do
   562→   562→          json.object do
   563→   563→            json.field "outbound", [] of String
   564→   564→            json.field "inbound", [] of String
   565→   565→          end
   566→   566→        end
   567→   567→        json.field "usage" do
   568→   568→          json.object do
   569→   569→            json.field "users" do
   570→   570→              json.object do
   571→   571→                json.field "total", local_users
   572→   572→                json.field "activeMonth", local_users
   573→   573→                json.field "activeHalfyear", local_users
   574→   574→              end
   575→   575→            end
   576→   576→            json.field "localPosts", 0
   577→   577→          end
   578→   578→        end
   579→   579→        json.field "openRegistrations", !INVITE_CODES.empty?
   580→   580→        json.field "metadata" do
   581→   581→          json.object do
   582→   582→            json.field "nodeName", "josie.place"
   583→   583→            json.field "nodeDescription", "Live location sharing"
   584→   584→          end
   585→   585→        end
   586→   586→      end
   587→   587→    end)
   588→   588→
   589→   589→    # ============================================
   590→   590→    # Mastodon OAuth: Start login flow
   591→   591→    # ============================================
   592→   592→  when {"/api/auth/start", "POST"}
   593→   593→    begin
   594→   594→      body = context.request.body.try(&.gets_to_end) || ""
   595→   595→      data = JSON.parse(body)
   596→   596→
   597→   597→      instance = data["instance"]?.try(&.as_s?)
   598→   598→      unless instance
   599→   599→        context.response.status = HTTP::Status::BAD_REQUEST
   600→   600→        context.response.print(%({"error": "Missing instance parameter"}))
   601→   601→        next
   602→   602→      end
   603→   603→
   604→   604→      is_native = data["native"]?.try(&.as_bool?) || false
   605→   605→
   606→   606→      # Normalize instance domain
   607→   607→      instance = instance.downcase.strip
   608→   608→      instance = instance.sub(/^https?:\/\//, "").split("/").first
   609→   609→
   610→   610→      redirect_uri = "#{API_BASE_URL}/api/auth/callback"
   611→   611→
   612→   612→      # Detect instance type
   613→   613→      instance_type = MastodonOAuth.detect_instance_type(instance)
   614→   614→      if instance_type == MastodonOAuth::InstanceType::Unknown
   615→   615→        context.response.status = HTTP::Status::BAD_GATEWAY
   616→   616→        context.response.print(%({"error": "Unknown instance type"}))
   617→   617→        next
   618→   618→      end
   619→   619→
   620→   620→      state = MastodonOAuth.generate_state
   621→   621→      auth_url : String
   622→   622→
   623→   623→      if instance_type == MastodonOAuth::InstanceType::Misskey
   624→   624→        # Misskey/Sharkey: Use MiAuth (simpler and more reliable than their OAuth)
   625→   625→        session_id = MastodonOAuth.generate_miauth_session
   626→   626→
   627→   627→        # Store session_id in code_verifier field (reusing existing schema)
   628→   628→        oauth_state = MastodonOAuth::OAuthState.new(state, instance, session_id, instance_type, is_native)
   629→   629→        ACTOR_STORE.save_oauth_state(oauth_state)
   630→   630→
   631→   631→        # MiAuth callback includes the session ID, so we encode state in the callback URL
   632→   632→        callback_url = "#{redirect_uri}?state=#{state}"
   633→   633→        auth_url = MastodonOAuth.miauth_authorization_url(instance, session_id, callback_url)
   634→   634→      else
   635→   635→        # Mastodon: Register app and use standard OAuth
   636→   636→        app = ACTOR_STORE.get_oauth_app(instance)
   637→   637→        unless app
   638→   638→          app = MastodonOAuth.register_app(instance, redirect_uri)
   639→   639→          unless app
   640→   640→            context.response.status = HTTP::Status::BAD_GATEWAY
   641→   641→            context.response.print(%({"error": "Failed to register with instance"}))
   642→   642→            next
   643→   643→          end
   644→   644→          ACTOR_STORE.save_oauth_app(app)
   645→   645→        end
   646→   646→
   647→   647→        oauth_state = MastodonOAuth::OAuthState.new(state, instance, nil, instance_type, is_native)
   648→   648→        ACTOR_STORE.save_oauth_state(oauth_state)
   649→   649→
   650→   650→        auth_url = MastodonOAuth.authorization_url(instance, app.client_id, redirect_uri, state)
   651→   651→      end
   652→   652→
   653→   653→      # Clean up old states
   654→   654→      ACTOR_STORE.cleanup_old_oauth_states
   655→   655→
   656→   656→      context.response.content_type = CT_JSON
   657→   657→      context.response.print(%({"url": "#{auth_url}"}))
   658→   658→    rescue ex
   659→   659→      puts "Auth start error: #{ex.message}"
   660→   660→      context.response.status = HTTP::Status::BAD_REQUEST
   661→   661→      context.response.print(%({"error": "Invalid request"}))
   662→   662→    end
   663→   663→    # ============================================
   664→   664→    # Mastodon OAuth / MiAuth: Callback
   665→   665→    # ============================================
   666→   666→  when {"/api/auth/callback", "GET"}
   667→   667→    code = context.request.query_params["code"]?
   668→   668→    state = context.request.query_params["state"]?
   669→   669→
   670→   670→    # state is required for both flows
   671→   671→    unless state
   672→   672→      context.response.status = HTTP::Status::BAD_REQUEST
   673→   673→      context.response.content_type = "text/html"
   674→   674→      context.response.print("<h1>Error</h1><p>Missing state parameter</p>")
   675→   675→      next
   676→   676→    end
   677→   677→
   678→   678→    # Verify state
   679→   679→    oauth_state = ACTOR_STORE.get_oauth_state(state)
   680→   680→    unless oauth_state
   681→   681→      context.response.status = HTTP::Status::BAD_REQUEST
   682→   682→      context.response.content_type = "text/html"
   683→   683→      context.response.print("<h1>Error</h1><p>Invalid or expired state</p>")
   684→   684→      next
   685→   685→    end
   686→   686→
   687→   687→    # For Mastodon, code is required; for MiAuth, it's not
   688→   688→    if oauth_state.instance_type == MastodonOAuth::InstanceType::Mastodon && !code
   689→   689→      context.response.status = HTTP::Status::BAD_REQUEST
   690→   690→      context.response.content_type = "text/html"
   691→   691→      context.response.print("<h1>Error</h1><p>Missing code parameter</p>")
   692→   692→      next
   693→   693→    end
   694→   694→
   695→   695→    # Delete used state
   696→   696→    ACTOR_STORE.delete_oauth_state(state)
   697→   697→
   698→   698→    # Check state age (10 min max)
   699→   699→    if Time.utc - oauth_state.created_at > 10.minutes
   700→   700→      context.response.status = HTTP::Status::BAD_REQUEST
   701→   701→      context.response.content_type = "text/html"
   702→   702→      context.response.print("<h1>Error</h1><p>State expired</p>")
   703→   703→      next
   704→   704→    end
   705→   705→
   706→   706→    instance = oauth_state.instance_domain
   707→   707→    instance_type = oauth_state.instance_type
   708→   708→    redirect_uri = "#{API_BASE_URL}/api/auth/callback"
   709→   709→
   710→   710→    # Exchange code for token (different flow for Misskey vs Mastodon)
   711→   711→    access_token : String?
   712→   712→    account_id : String
   713→   713→    account_username : String
   714→   714→    account_display_name : String
   715→   715→    account_url : String
   716→   716→
   717→   717→    if instance_type == MastodonOAuth::InstanceType::Misskey
   718→   718→      # Misskey/Sharkey: Use MiAuth - session_id is stored in code_verifier field
   719→   719→      session_id = oauth_state.code_verifier
   720→   720→      unless session_id
   721→   721→        context.response.status = HTTP::Status::BAD_REQUEST
   722→   722→        context.response.content_type = "text/html"
   723→   723→        context.response.print("<h1>Error</h1><p>Missing session_id for MiAuth flow</p>")
   724→   724→        next
   725→   725→      end
   726→   726→
   727→   727→      # MiAuth check - no code needed, just the session ID
   728→   728→      access_token = MastodonOAuth.miauth_check(instance, session_id)
   729→   729→      unless access_token
   730→   730→        context.response.status = HTTP::Status::BAD_GATEWAY
   731→   731→        context.response.content_type = "text/html"
   732→   732→        context.response.print("<h1>Error</h1><p>Failed to get access token from MiAuth</p>")
   733→   733→        next
   734→   734→      end
   735→   735→
   736→   736→      # Get Misskey user info
   737→   737→      misskey_account = MastodonOAuth.misskey_verify_credentials(instance, access_token)
   738→   738→      unless misskey_account
   739→   739→        context.response.status = HTTP::Status::BAD_GATEWAY
   740→   740→        context.response.content_type = "text/html"
   741→   741→        context.response.print("<h1>Error</h1><p>Failed to verify credentials</p>")
   742→   742→        next
   743→   743→      end
   744→   744→
   745→   745→      account_id = misskey_account.id
   746→   746→      account_username = misskey_account.username
   747→   747→      account_display_name = misskey_account.name || misskey_account.username
   748→   748→      account_url = "https://#{instance}/@#{misskey_account.username}"
   749→   749→    else
   750→   750→      # Mastodon: Standard OAuth flow
   751→   751→      app = ACTOR_STORE.get_oauth_app(instance)
   752→   752→      unless app
   753→   753→        context.response.status = HTTP::Status::INTERNAL_SERVER_ERROR
   754→   754→        context.response.content_type = "text/html"
   755→   755→        context.response.print("<h1>Error</h1><p>App not found for instance</p>")
   756→   756→        next
   757→   757→      end
   758→   758→
   759→   759→      access_token = MastodonOAuth.exchange_code(instance, app.client_id, app.client_secret, redirect_uri, code.not_nil!)
   760→   760→      unless access_token
   761→   761→        context.response.status = HTTP::Status::BAD_GATEWAY
   762→   762→        context.response.content_type = "text/html"
   763→   763→        context.response.print("<h1>Error</h1><p>Failed to get access token</p>")
   764→   764→        next
   765→   765→      end
   766→   766→
   767→   767→      # Get Mastodon user info
   768→   768→      account = MastodonOAuth.verify_credentials(instance, access_token)
   769→   769→      unless account
   770→   770→        context.response.status = HTTP::Status::BAD_GATEWAY
   771→   771→        context.response.content_type = "text/html"
   772→   772→        context.response.print("<h1>Error</h1><p>Failed to verify credentials</p>")
   773→   773→        next
   774→   774→      end
   775→   775→
   776→   776→      account_id = account.id
   777→   777→      account_username = account.username
   778→   778→      account_display_name = account.display_name
   779→   779→      account_url = account.url
   780→   780→    end
   781→   781→
   782→   782→    # Find or create local actor
   783→   783→    actor = ACTOR_STORE.find_actor_by_remote(instance, account_id)
   784→   784→
   785→   785→    unless actor
   786→   786→      # Create new local actor linked to this remote account
   787→   787→      # Format: username_domain_tld (e.g., josie_fedi_josie_place)
   788→   788→      username = "#{account_username}_#{instance.gsub(".", "_")}"
   789→   789→      # Ensure unique username
   790→   790→      base_username = username
   791→   791→      counter = 1
   792→   792→      while ACTOR_STORE.find_by_username(username)
   793→   793→        username = "#{base_username}#{counter}"
   794→   794→        counter += 1
   795→   795→      end
   796→   796→
   797→   797→      actor = Actor.create_local(username, DOMAIN, account_display_name)
   798→   798→      ACTOR_STORE.create(actor)
   799→   799→
   800→   800→      # Link to remote account
   801→   801→      link = MastodonOAuth::ActorLink.new(
   802→   802→        local_actor_id: actor.id,
   803→   803→        remote_instance: instance,
   804→   804→        remote_account_id: account_id,
   805→   805→        remote_actor_url: account_url,
   806→   806→        remote_username: account_username,
   807→   807→        access_token: access_token
   808→   808→      )
   809→   809→      ACTOR_STORE.save_actor_link(link)
   810→   810→    end
   811→   811→
   812→   812→    # Return response based on client type
   813→   813→    if oauth_state.is_native
   814→   814→      # Native app: redirect to custom URL scheme
   815→   815→      native_url = "josieplace://oauth-callback?token=#{actor.token}&username=#{URI.encode_path_segment(actor.username)}&actorId=#{URI.encode_path_segment(actor.id)}"
   816→   816→      context.response.status = HTTP::Status::FOUND
   817→   817→      context.response.headers["Location"] = native_url
   818→   818→    else
   819→   819→      # Web: HTML page that posts token to opener and closes
   820→   820→      context.response.content_type = "text/html"
   821→   821→      context.response.print(<<-HTML
   822→   822→      <!DOCTYPE html>
   823→   823→      <html>
   824→   824→      <head><title>Login successful</title></head>
   825→   825→      <body>
   826→   826→        <h1>Login successful</h1>
   827→   827→        <p>You can close this window.</p>
   828→   828→        <script>
   829→   829→          if (window.opener) {
   830→   830→            window.opener.postMessage({
   831→   831→              type: 'oauth_success',
   832→   832→              token: '#{actor.token}',
   833→   833→              username: '#{actor.username}',
   834→   834→              actorId: '#{actor.id}'
   835→   835→            }, 'https://#{DOMAIN}');
   836→   836→            window.close();
   837→   837→          }
   838→   838→        </script>
   839→   839→      </body>
   840→   840→      </html>
   841→   841→      HTML
   842→   842→      )
   843→   843→    end
   844→   844→
   845→   845→    # ============================================
   846→   846→    # Shared Inbox (for efficiency)
   847→   847→    # ============================================
   848→   848→  when {"/inbox", "POST"}
   849→   849→    actor = check_http_signature(context)
   850→   850→    unless actor
   851→   851→      context.response.status = HTTP::Status::UNAUTHORIZED
   852→   852→      context.response.print(%({"error": "Invalid signature"}))
   853→   853→      next
   854→   854→    end
   855→   855→
   856→   856→    body = context.request.body.try(&.gets_to_end) || ""
   857→   857→    activity = Activity.from_json_any(JSON.parse(body))
   858→   858→    unless activity
   859→   859→      context.response.status = HTTP::Status::BAD_REQUEST
   860→   860→      context.response.print(%({"error": "Invalid activity"}))
   861→   861→      next
   862→   862→    end
   863→   863→
   864→   864→    # Route to appropriate local actor inbox
   865→   865→    # For now, just accept
   866→   866→    context.response.status = HTTP::Status::ACCEPTED
   867→   867→    context.response.print(%({"ok": true}))
   868→   868→
   869→   869→    # ============================================
   870→   870→    # Lists API: Get all lists
   871→   871→    # ============================================
   872→   872→  when {"/api/lists", "GET"}
   873→   873→    auth = check_auth(context)
   874→   874→    unless auth.authenticated && auth.actor
   875→   875→      context.response.status = HTTP::Status::UNAUTHORIZED
   876→   876→      context.response.print(%({"error": "Unauthorized"}))
   877→   877→      next
   878→   878→    end
   879→   879→
   880→   880→    lists = ACTOR_STORE.get_lists_for_actor(auth.actor.not_nil!.id)
   881→   881→    context.response.content_type = CT_JSON
   882→   882→    context.response.print(JSON.build do |json|
   883→   883→      json.array do
   884→   884→        lists.each { |list| list.to_json(json) }
   885→   885→      end
   886→   886→    end)
   887→   887→
   888→   888→    # ============================================
   889→   889→    # Lists API: Create a list
   890→   890→    # ============================================
   891→   891→  when {"/api/lists", "POST"}
   892→   892→    auth = check_auth(context)
   893→   893→    unless auth.authenticated && auth.actor
   894→   894→      context.response.status = HTTP::Status::UNAUTHORIZED
   895→   895→      context.response.print(%({"error": "Unauthorized"}))
   896→   896→      next
   897→   897→    end
   898→   898→
   899→   899→    begin
   900→   900→      body = context.request.body.try(&.gets_to_end) || ""
   901→   901→      data = JSON.parse(body)
   902→   902→
   903→   903→      name = data["name"]?.try(&.as_s?)
   904→   904→      unless name && !name.empty?
   905→   905→        context.response.status = HTTP::Status::BAD_REQUEST
   906→   906→        context.response.print(%({"error": "Name is required"}))
   907→   907→        next
   908→   908→      end
   909→   909→
   910→   910→      interval = data["update_interval_seconds"]?.try(&.as_i?) || 30
   911→   911→      accuracy_str = data["accuracy"]?.try(&.as_s?) || "exact"
   912→   912→      accuracy = LocationAccuracy.parse?(accuracy_str) || LocationAccuracy::Exact
   913→   913→
   914→   914→      list = LocationList.new(
   915→   915→        id: LocationList.generate_id,
   916→   916→        owner_actor_id: auth.actor.not_nil!.id,
   917→   917→        name: name,
   918→   918→        update_interval_seconds: interval,
   919→   919→        accuracy: accuracy
   920→   920→      )
   921→   921→      ACTOR_STORE.create_list(list)
   922→   922→
   923→   923→      context.response.status = HTTP::Status::CREATED
   924→   924→      context.response.content_type = CT_JSON
   925→   925→      context.response.print(JSON.build { |json| list.to_json(json) })
   926→   926→    rescue ex
   927→   927→      context.response.status = HTTP::Status::BAD_REQUEST
   928→   928→      context.response.print(%({"error": "Invalid request"}))
   929→   929→    end
   930→   930→
   931→   931→    # ============================================
   932→   932→    # API: Create session (with optional list_ids for federated broadcasting)
   933→   933→    # ============================================
   934→   934→  when {"/api/session", "POST"}
   935→   935→    auth = check_auth(context)
   936→   936→    unless auth.authenticated
   937→   937→      context.response.status = HTTP::Status::UNAUTHORIZED
   938→   938→      context.response.print(%({"error": "Unauthorized"}))
   939→   939→      next
   940→   940→    end
   941→   941→
   942→   942→    owner_id = auth.actor.try(&.id)
   943→   943→    list_ids = [] of String
   944→   944→
   945→   945→    # Parse optional list_ids from request body
   946→   946→    begin
   947→   947→      body = context.request.body.try(&.gets_to_end) || ""
   948→   948→      unless body.empty?
   949→   949→        data = JSON.parse(body)
   950→   950→        if ids = data["list_ids"]?.try(&.as_a?)
   951→   951→          list_ids = ids.compact_map(&.as_s?)
   952→   952→        end
   953→   953→      end
   954→   954→    rescue
   955→   955→      # Ignore parse errors, just use empty list_ids
   956→   956→    end
   957→   957→
   958→   958→    session = SESSIONS.create(owner_id, SessionVisibility::Public, list_ids)
   959→   959→    context.response.content_type = CT_JSON
   960→   960→    context.response.print(JSON.build do |json|
   961→   961→      json.object do
   962→   962→        json.field "id", session.id
   963→   963→        json.field "list_ids", list_ids
   964→   964→      end
   965→   965→    end)
   966→   966→
   967→   967→    # ============================================
   968→   968→    # Health check
   969→   969→    # ============================================
   970→   970→  when {"/health", "GET"}
   971→   971→    context.response.content_type = CT_JSON
   972→   972→    context.response.print(%({"status": "ok"}))
   973→   973→
   974→   974→    # ============================================
   975→   975→    # Test endpoint: Add fake friend location (dev only)
   976→   976→    # ============================================
   977→   977→  when {"/api/test/friend-location", "POST"}
   978→   978→    auth = check_auth(context)
   979→   979→    unless auth.authenticated && auth.actor
   980→   980→      context.response.status = HTTP::Status::UNAUTHORIZED
   981→   981→      context.response.print(%({"error": "Unauthorized"}))
   982→   982→      next
   983→   983→    end
   984→   984→
   985→   985→    begin
   986→   986→      body = context.request.body.try(&.gets_to_end) || ""
   987→   987→      data = JSON.parse(body)
   988→   988→
   989→   989→      actor = auth.actor.not_nil!
   990→   990→      friend_loc = FriendLocation.new(
   991→   991→        remote_actor_url: data["actor_url"].as_s,
   992→   992→        remote_actor_acct: data["acct"].as_s,
   993→   993→        lat: data["lat"].as_f,
   994→   994→        lng: data["lng"].as_f,
   995→   995→        accuracy: data["accuracy"]?.try(&.as_f?),
   996→   996→        updated_at: Time.utc
   997→   997→      )
   998→   998→      FRIEND_LOCATIONS.update(actor.id, friend_loc)
   999→   999→
  1000→  1000→      context.response.content_type = CT_JSON
  1001→  1001→      context.response.print(%({"ok": true}))
  1002→  1002→    rescue ex
  1003→  1003→      context.response.status = HTTP::Status::BAD_REQUEST
  1004→  1004→      context.response.print(%({"error": "#{ex.message}"}))
  1005→  1005→    end
  1006→  1006→  else
  1007→  1007→    # ============================================
  1008→  1008→    # Lists API: /api/lists/:id/*
  1009→  1009→    # ============================================
  1010→  1010→    if path.starts_with?("/api/lists/")
  1011→  1011→      auth = check_auth(context)
  1012→  1012→      unless auth.authenticated && auth.actor
  1013→  1013→        context.response.status = HTTP::Status::UNAUTHORIZED
  1014→  1014→        context.response.print(%({"error": "Unauthorized"}))
  1015→  1015→        next
  1016→  1016→      end
  1017→  1017→
  1018→  1018→      actor = auth.actor.not_nil!
  1019→  1019→      parts = path.sub("/api/lists/", "").split("/")
  1020→  1020→      list_id = parts[0]
  1021→  1021→      subpath = parts.size > 1 ? "/#{parts[1..-1].join("/")}" : ""
  1022→  1022→
  1023→  1023→      list = ACTOR_STORE.get_list(list_id)
  1024→  1024→      unless list && list.owner_actor_id == actor.id
  1025→  1025→        context.response.status = HTTP::Status::NOT_FOUND
  1026→  1026→        context.response.print(%({"error": "List not found"}))
  1027→  1027→        next
  1028→  1028→      end
  1029→  1029→
  1030→  1030→      case {subpath, method}
  1031→  1031→      # Get single list
  1032→  1032→      when {"", "GET"}
  1033→  1033→        context.response.content_type = CT_JSON
  1034→  1034→        context.response.print(JSON.build { |json| list.to_json(json) })
  1035→  1035→
  1036→  1036→        # Update list
  1037→  1037→      when {"", "PATCH"}
  1038→  1038→        begin
  1039→  1039→          body = context.request.body.try(&.gets_to_end) || ""
  1040→  1040→          data = JSON.parse(body)
  1041→  1041→
  1042→  1042→          updated_list = LocationList.new(
  1043→  1043→            id: list.id,
  1044→  1044→            owner_actor_id: list.owner_actor_id,
  1045→  1045→            name: data["name"]?.try(&.as_s?) || list.name,
  1046→  1046→            update_interval_seconds: data["update_interval_seconds"]?.try(&.as_i?) || list.update_interval_seconds,
  1047→  1047→            accuracy: data["accuracy"]?.try(&.as_s?).try { |s| LocationAccuracy.parse?(s) } || list.accuracy,
  1048→  1048→            created_at: list.created_at,
  1049→  1049→            updated_at: Time.utc
  1050→  1050→          )
  1051→  1051→          ACTOR_STORE.update_list(updated_list)
  1052→  1052→
  1053→  1053→          context.response.content_type = CT_JSON
  1054→  1054→          context.response.print(JSON.build { |json| updated_list.to_json(json) })
  1055→  1055→        rescue ex
  1056→  1056→          context.response.status = HTTP::Status::BAD_REQUEST
  1057→  1057→          context.response.print(%({"error": "Invalid request"}))
  1058→  1058→        end
  1059→  1059→        # Delete list
  1060→  1060→      when {"", "DELETE"}
  1061→  1061→        ACTOR_STORE.delete_list(list_id)
  1062→  1062→        context.response.status = HTTP::Status::NO_CONTENT
  1063→  1063→
  1064→  1064→        # Get list members
  1065→  1065→      when {"/members", "GET"}
  1066→  1066→        members = ACTOR_STORE.get_list_members(list_id)
  1067→  1067→        context.response.content_type = CT_JSON
  1068→  1068→        context.response.print(JSON.build do |json|
  1069→  1069→          json.array do
  1070→  1070→            members.each { |m| m.to_json(json) }
  1071→  1071→          end
  1072→  1072→        end)
  1073→  1073→
  1074→  1074→        # Add member to list
  1075→  1075→      when {"/members", "POST"}
  1076→  1076→        begin
  1077→  1077→          body = context.request.body.try(&.gets_to_end) || ""
  1078→  1078→          data = JSON.parse(body)
  1079→  1079→
  1080→  1080→          acct = data["acct"]?.try(&.as_s?)
  1081→  1081→          unless acct && !acct.empty?
  1082→  1082→            context.response.status = HTTP::Status::BAD_REQUEST
  1083→  1083→            context.response.print(%({"error": "acct is required (e.g., user@example.com)"}))
  1084→  1084→            next
  1085→  1085→          end
  1086→  1086→
  1087→  1087→          # Normalize acct: trim whitespace and remove leading @
  1088→  1088→          acct = acct.strip
  1089→  1089→          if acct.starts_with?('@')
  1090→  1090→            acct = acct[1..]
  1091→  1091→          end
  1092→  1092→
  1093→  1093→          puts "Adding member: acct=#{acct}"
  1094→  1094→
  1095→  1095→          # Check if already a member
  1096→  1096→          existing = ACTOR_STORE.get_list_members(list_id).find { |m| m.remote_actor_acct == acct }
  1097→  1097→          if existing
  1098→  1098→            context.response.status = HTTP::Status::CONFLICT
  1099→  1099→            context.response.print(%({"error": "Already a member"}))
  1100→  1100→            next
  1101→  1101→          end
  1102→  1102→
  1103→  1103→          # Validate acct format
  1104→  1104→          parts = acct.split("@")
  1105→  1105→          if parts.size != 2
  1106→  1106→            context.response.status = HTTP::Status::BAD_REQUEST
  1107→  1107→            context.response.print(%({"error": "Invalid acct format"}))
  1108→  1108→            next
  1109→  1109→          end
  1110→  1110→
  1111→  1111→          # Lookup actor via WebFinger
  1112→  1112→          remote_actor_url = Federation.webfinger(acct)
  1113→  1113→          unless remote_actor_url
  1114→  1114→            context.response.status = HTTP::Status::NOT_FOUND
  1115→  1115→            context.response.print(%({"error": "Could not find user via WebFinger"}))
  1116→  1116→            next
  1117→  1117→          end
  1118→  1118→
  1119→  1119→          # Fetch remote actor to get their inbox (signed for servers that require it)
  1120→  1120→          remote_actor = Federation.fetch_actor(remote_actor_url, actor)
  1121→  1121→          unless remote_actor
  1122→  1122→            context.response.status = HTTP::Status::NOT_FOUND
  1123→  1123→            context.response.print(%({"error": "Could not fetch remote actor"}))
  1124→  1124→            next
  1125→  1125→          end
  1126→  1126→
  1127→  1127→          member = ListMember.new(
  1128→  1128→            id: ListMember.generate_id,
  1129→  1129→            list_id: list_id,
  1130→  1130→            remote_actor_url: remote_actor_url,
  1131→  1131→            remote_actor_acct: acct,
  1132→  1132→            status: "pending"
  1133→  1133→          )
  1134→  1134→          ACTOR_STORE.add_list_member(member)
  1135→  1135→
  1136→  1136→          # Check if this remote user has a local josie.place account
  1137→  1137→          # Use username/domain since actor_links stores web profile URL, not ActivityPub URL
  1138→  1138→          local_recipient = ACTOR_STORE.find_local_actor_by_remote_acct(parts[0], parts[1])
  1139→  1139→          if local_recipient
  1140→  1140→            # Create IncomingShare directly for local user
  1141→  1141→            puts "Creating local incoming share for #{local_recipient.username}"
  1142→  1142→            existing_share = ACTOR_STORE.find_incoming_share_by_actor(local_recipient.id, actor.id)
  1143→  1143→            unless existing_share
  1144→  1144→              share = IncomingShare.new(
  1145→  1145→                id: IncomingShare.generate_id,
  1146→  1146→                local_actor_id: local_recipient.id,
  1147→  1147→                remote_actor_url: actor.id,
  1148→  1148→                remote_actor_acct: actor.acct
  1149→  1149→              )
  1150→  1150→              ACTOR_STORE.create_incoming_share(share)
  1151→  1151→              puts "Created incoming share from #{actor.acct} to #{local_recipient.username}"
  1152→  1152→            end
  1153→  1153→          else
  1154→  1154→            # Send ActivityPub Invite to remote actor
  1155→  1155→            puts "Sending ActivityPub invite to #{remote_actor.inbox}"
  1156→  1156→            invite_id = "https://#{DOMAIN}/activities/#{Random::Secure.hex(16)}"
  1157→  1157→            invite_activity = JSON.build do |json|
  1158→  1158→              json.object do
  1159→  1159→                json.field "@context", "https://www.w3.org/ns/activitystreams"
  1160→  1160→                json.field "id", invite_id
  1161→  1161→                json.field "type", "Invite"
  1162→  1162→                json.field "actor", actor.id
  1163→  1163→                json.field "object" do
  1164→  1164→                  json.object do
  1165→  1165→                    json.field "type", "Application"
  1166→  1166→                    json.field "name", "josie.place Location Sharing"
  1167→  1167→                  end
  1168→  1168→                end
  1169→  1169→                json.field "target", remote_actor_url
  1170→  1170→                json.field "to", [remote_actor_url]
  1171→  1171→              end
  1172→  1172→            end
  1173→  1173→
  1174→  1174→            # Queue delivery
  1175→  1175→            DELIVERY_QUEUE.enqueue(remote_actor.inbox, invite_activity, actor)
  1176→  1176→          end
  1177→  1177→
  1178→  1178→          context.response.status = HTTP::Status::CREATED
  1179→  1179→          context.response.content_type = CT_JSON
  1180→  1180→          context.response.print(JSON.build { |json| member.to_json(json) })
  1181→  1181→        rescue ex
  1182→  1182→          puts "Add member error: #{ex.message}"
  1183→  1183→          context.response.status = HTTP::Status::BAD_REQUEST
  1184→  1184→          context.response.print(%({"error": "Invalid request"}))
  1185→  1185→        end
  1186→  1186→      else
  1187→  1187→        # Check for /members/:member_id DELETE
  1188→  1188→        if subpath.starts_with?("/members/") && method == "DELETE"
  1189→  1189→          member_id = subpath.sub("/members/", "")
  1190→  1190→          puts "Delete member: list_id=#{list_id}, member_id=#{member_id}"
  1191→  1191→          member = ACTOR_STORE.get_list_member(member_id)
  1192→  1192→          puts "Found member: #{member.inspect}"
  1193→  1193→          if member && member.list_id == list_id
  1194→  1194→            ACTOR_STORE.delete_list_member(member_id)
  1195→  1195→            context.response.status = HTTP::Status::NO_CONTENT
  1196→  1196→          else
  1197→  1197→            puts "Member not found or list_id mismatch"
  1198→  1198→            context.response.status = HTTP::Status::NOT_FOUND
  1199→  1199→            context.response.print(%({"error": "Member not found"}))
  1200→  1200→          end
  1201→  1201→        else
  1202→  1202→          context.response.status = HTTP::Status::NOT_FOUND
  1203→  1203→          context.response.print(%({"error": "Not found"}))
  1204→  1204→        end
  1205→  1205→      end
  1206→  1206→      next
  1207→  1207→    end
  1208→  1208→
  1209→  1209→    # ============================================
  1210→  1210→    # Incoming Shares API: /api/shares/incoming/*
  1211→  1211→    # ============================================
  1212→  1212→    if path.starts_with?("/api/shares/incoming")
  1213→  1213→      auth = check_auth(context)
  1214→  1214→      unless auth.authenticated && auth.actor
  1215→  1215→        context.response.status = HTTP::Status::UNAUTHORIZED
  1216→  1216→        context.response.print(%({"error": "Unauthorized"}))
  1217→  1217→        next
  1218→  1218→      end
  1219→  1219→
  1220→  1220→      actor = auth.actor.not_nil!
  1221→  1221→
  1222→  1222→      case {path, method}
  1223→  1223→      # Get all incoming shares
  1224→  1224→      when {"/api/shares/incoming", "GET"}
  1225→  1225→        shares = ACTOR_STORE.get_incoming_shares_for_actor(actor.id)
  1226→  1226→        context.response.content_type = CT_JSON
  1227→  1227→        context.response.print(JSON.build do |json|
  1228→  1228→          json.array do
  1229→  1229→            shares.each { |s| s.to_json(json) }
  1230→  1230→          end
  1231→  1231→        end)
  1232→  1232→      else
  1233→  1233→        # Handle /api/shares/incoming/:id/accept or /decline
  1234→  1234→        if path =~ /^\/api\/shares\/incoming\/([^\/]+)\/(accept|decline)$/ && method == "POST"
  1235→  1235→          share_id = $1
  1236→  1236→          action = $2
  1237→  1237→
  1238→  1238→          share = ACTOR_STORE.get_incoming_share(share_id)
  1239→  1239→          unless share && share.local_actor_id == actor.id
  1240→  1240→            context.response.status = HTTP::Status::NOT_FOUND
  1241→  1241→            context.response.print(%({"error": "Share not found"}))
  1242→  1242→            next
  1243→  1243→          end
  1244→  1244→
  1245→  1245→          new_status = action == "accept" ? "accepted" : "declined"
  1246→  1246→          accepted_at = action == "accept" ? Time.utc : nil
  1247→  1247→          ACTOR_STORE.update_incoming_share_status(share_id, new_status, accepted_at)
  1248→  1248→
  1249→  1249→          context.response.content_type = CT_JSON
  1250→  1250→          context.response.print(%({"status": "#{new_status}"}))
  1251→  1251→        else
  1252→  1252→          context.response.status = HTTP::Status::NOT_FOUND
  1253→  1253→          context.response.print(%({"error": "Not found"}))
  1254→  1254→        end
  1255→  1255→      end
  1256→  1256→      next
  1257→  1257→    end
  1258→  1258→
  1259→  1259→    # ============================================
  1260→  1260→    # Friend Locations API: /api/friends/locations
  1261→  1261→    # ============================================
  1262→  1262→    if path == "/api/friends/locations" && method == "GET"
  1263→  1263→      auth = check_auth(context)
  1264→  1264→      unless auth.authenticated && auth.actor
  1265→  1265→        context.response.status = HTTP::Status::UNAUTHORIZED
  1266→  1266→        context.response.print(%({"error": "Unauthorized"}))
  1267→  1267→        next
  1268→  1268→      end
  1269→  1269→
  1270→  1270→      actor = auth.actor.not_nil!
  1271→  1271→      locations = FRIEND_LOCATIONS.get_all(actor.id)
  1272→  1272→
  1273→  1273→      context.response.content_type = CT_JSON
  1274→  1274→      context.response.print(JSON.build do |json|
  1275→  1275→        json.array do
  1276→  1276→          locations.each { |loc| loc.to_json(json) }
  1277→  1277→        end
  1278→  1278→      end)
  1279→  1279→      next
  1280→  1280→    end
  1281→  1281→
  1282→  1282→    # ============================================
  1283→  1283→    # Actor endpoints: /users/:username/*
  1284→  1284→    # ============================================
  1285→  1285→    if username = extract_username(path)
  1286→  1286→      actor = ACTOR_STORE.find_by_username(username)
  1287→  1287→
  1288→  1288→      unless actor
  1289→  1289→        context.response.status = HTTP::Status::NOT_FOUND
  1290→  1290→        context.response.content_type = CT_JSON
  1291→  1291→        context.response.print(%({"error": "Actor not found"}))
  1292→  1292→        next
  1293→  1293→      end
  1294→  1294→
  1295→  1295→      subpath = path.sub("/users/#{username}", "")
  1296→  1296→
  1297→  1297→      case {subpath, method}
  1298→  1298→      # Actor profile
  1299→  1299→      when {"", "GET"}, {"/", "GET"}
  1300→  1300→        if wants_activitypub?(context)
  1301→  1301→          context.response.content_type = CT_ACTIVITYPUB
  1302→  1302→          context.response.print(actor.to_activitypub)
  1303→  1303→        else
  1304→  1304→          context.response.content_type = CT_JSON
  1305→  1305→          context.response.print(actor.to_json)
  1306→  1306→        end
  1307→  1307→        # Inbox
  1308→  1308→      when {"/inbox", "POST"}
  1309→  1309→        remote_actor = check_http_signature(context)
  1310→  1310→        unless remote_actor
  1311→  1311→          context.response.status = HTTP::Status::UNAUTHORIZED
  1312→  1312→          context.response.print(%({"error": "Invalid signature"}))
  1313→  1313→          next
  1314→  1314→        end
  1315→  1315→
  1316→  1316→        body = context.request.body.try(&.gets_to_end) || ""
  1317→  1317→        data = JSON.parse(body)
  1318→  1318→        activity = Activity.from_json_any(data)
  1319→  1319→
  1320→  1320→        unless activity
  1321→  1321→          context.response.status = HTTP::Status::BAD_REQUEST
  1322→  1322→          context.response.print(%({"error": "Invalid activity"}))
  1323→  1323→          next
  1324→  1324→        end
  1325→  1325→
  1326→  1326→        case activity.type
  1327→  1327→        when "Follow"
  1328→  1328→          target_id = activity.object_id
  1329→  1329→          if target_id == actor.id
  1330→  1330→            # Someone wants to follow this local actor
  1331→  1331→            follow = Follow.new(
  1332→  1332→              id: activity.id,
  1333→  1333→              actor_id: activity.actor,
  1334→  1334→              target_id: actor.id
  1335→  1335→            )
  1336→  1336→
  1337→  1337→            existing = FOLLOW_STORE.find_by_actor_and_target(activity.actor, actor.id)
  1338→  1338→            if existing
  1339→  1339→              FOLLOW_STORE.update_state(existing.id, FollowState::Accepted)
  1340→  1340→            else
  1341→  1341→              FOLLOW_STORE.create(follow)
  1342→  1342→              FOLLOW_STORE.update_state(follow.id, FollowState::Accepted)
  1343→  1343→            end
  1344→  1344→
  1345→  1345→            # Auto-accept: send Accept activity
  1346→  1346→            accept_activity = ActivityBuilder.create_accept(actor.id, data)
  1347→  1347→            DELIVERY_QUEUE.enqueue(remote_actor.inbox, accept_activity, actor)
  1348→  1348→          end
  1349→  1349→        when "Undo"
  1350→  1350→          inner = activity.object
  1351→  1351→          if inner.as_h? && inner["type"]?.try(&.as_s?) == "Follow"
  1352→  1352→            follower_id = inner["actor"]?.try(&.as_s?)
  1353→  1353→            if follower_id
  1354→  1354→              FOLLOW_STORE.delete_by_actor_and_target(follower_id, actor.id)
  1355→  1355→            end
  1356→  1356→          end
  1357→  1357→        when "Accept"
  1358→  1358→          # Our follow request was accepted
  1359→  1359→          inner = activity.object
  1360→  1360→          if inner.as_h? && inner["type"]?.try(&.as_s?) == "Follow"
  1361→  1361→            follow_id = inner["id"]?.try(&.as_s?)
  1362→  1362→            if follow_id
  1363→  1363→              FOLLOW_STORE.update_state(follow_id, FollowState::Accepted)
  1364→  1364→            end
  1365→  1365→          end
  1366→  1366→        when "Reject"
  1367→  1367→          inner = activity.object
  1368→  1368→          if inner.as_h? && inner["type"]?.try(&.as_s?) == "Follow"
  1369→  1369→            follow_id = inner["id"]?.try(&.as_s?)
  1370→  1370→            if follow_id
  1371→  1371→              FOLLOW_STORE.update_state(follow_id, FollowState::Rejected)
  1372→  1372→            end
  1373→  1373→          end
  1374→  1374→        when "Invite"
  1375→  1375→          # Someone wants to share their location with us
  1376→  1376→          obj = activity.object
  1377→  1377→          if obj.as_h? && obj["type"]?.try(&.as_s?) == "Application" && obj["name"]?.try(&.as_s?).try(&.includes?("Location Sharing"))
  1378→  1378→            # This is a josie.place location sharing invite
  1379→  1379→            remote_actor_url = activity.actor
  1380→  1380→            remote_acct = "#{remote_actor.username}@#{remote_actor.domain}"
  1381→  1381→
  1382→  1382→            # Check if we already have this share request
  1383→  1383→            existing = ACTOR_STORE.find_incoming_share_by_actor(actor.id, remote_actor_url)
  1384→  1384→            unless existing
  1385→  1385→              share = IncomingShare.new(
  1386→  1386→                id: IncomingShare.generate_id,
  1387→  1387→                local_actor_id: actor.id,
  1388→  1388→                remote_actor_url: remote_actor_url,
  1389→  1389→                remote_actor_acct: remote_acct
  1390→  1390→              )
  1391→  1391→              ACTOR_STORE.create_incoming_share(share)
  1392→  1392→              puts "Received location sharing invite from #{remote_acct} to #{actor.username}"
  1393→  1393→            end
  1394→  1394→          end
  1395→  1395→        when "Update"
  1396→  1396→          # Location update from a friend
  1397→  1397→          obj = activity.object
  1398→  1398→          if obj.as_h? && obj["type"]?.try(&.as_s?) == "Place"
  1399→  1399→            remote_actor_url = activity.actor
  1400→  1400→            remote_acct = "#{remote_actor.username}@#{remote_actor.domain}"
  1401→  1401→
  1402→  1402→            # Verify we have a symmetric relationship with this sender
  1403→  1403→            if ACTOR_STORE.can_share_with?(actor.id, remote_actor_url)
  1404→  1404→              lat = obj["latitude"]?.try(&.as_f?)
  1405→  1405→              lng = obj["longitude"]?.try(&.as_f?)
  1406→  1406→
  1407→  1407→              if lat && lng
  1408→  1408→                friend_loc = FriendLocation.new(
  1409→  1409→                  remote_actor_url: remote_actor_url,
  1410→  1410→                  remote_actor_acct: remote_acct,
  1411→  1411→                  lat: lat,
  1412→  1412→                  lng: lng,
  1413→  1413→                  accuracy: obj["accuracy"]?.try(&.as_f?),
  1414→  1414→                  speed: obj["speed"]?.try(&.as_f?),
  1415→  1415→                  heading: obj["heading"]?.try(&.as_f?),
  1416→  1416→                  updated_at: Time.utc
  1417→  1417→                )
  1418→  1418→                FRIEND_LOCATIONS.update(actor.id, friend_loc)
  1419→  1419→                puts "Updated location for #{remote_acct} -> #{actor.username}: #{lat}, #{lng}"
  1420→  1420→              end
  1421→  1421→            else
  1422→  1422→              puts "Rejected location update from #{remote_acct} - no symmetric relationship"
  1423→  1423→            end
  1424→  1424→          end
  1425→  1425→        end
  1426→  1426→
  1427→  1427→        context.response.status = HTTP::Status::ACCEPTED
  1428→  1428→        context.response.print(%({"ok": true}))
  1429→  1429→
  1430→  1430→        # Outbox
  1431→  1431→      when {"/outbox", "GET"}
  1432→  1432→        context.response.content_type = CT_ACTIVITYPUB
  1433→  1433→        context.response.print(JSON.build do |json|
  1434→  1434→          json.object do
  1435→  1435→            json.field "@context", "https://www.w3.org/ns/activitystreams"
  1436→  1436→            json.field "type", "OrderedCollection"
  1437→  1437→            json.field "id", actor.outbox
  1438→  1438→            json.field "totalItems", 0
  1439→  1439→            json.field "orderedItems", [] of String
  1440→  1440→          end
  1441→  1441→        end)
  1442→  1442→
  1443→  1443→        # Followers collection
  1444→  1444→      when {"/followers", "GET"}
  1445→  1445→        followers = FOLLOW_STORE.followers_of(actor.id)
  1446→  1446→        context.response.content_type = CT_ACTIVITYPUB
  1447→  1447→        context.response.print(JSON.build do |json|
  1448→  1448→          json.object do
  1449→  1449→            json.field "@context", "https://www.w3.org/ns/activitystreams"
  1450→  1450→            json.field "type", "OrderedCollection"
  1451→  1451→            json.field "id", actor.followers_url
  1452→  1452→            json.field "totalItems", followers.size
  1453→  1453→            json.field "orderedItems", followers.map(&.actor_id)
  1454→  1454→          end
  1455→  1455→        end)
  1456→  1456→
  1457→  1457→        # Following collection
  1458→  1458→      when {"/following", "GET"}
  1459→  1459→        following = FOLLOW_STORE.following_of(actor.id)
  1460→  1460→        context.response.content_type = CT_ACTIVITYPUB
  1461→  1461→        context.response.print(JSON.build do |json|
  1462→  1462→          json.object do
  1463→  1463→            json.field "@context", "https://www.w3.org/ns/activitystreams"
  1464→  1464→            json.field "type", "OrderedCollection"
  1465→  1465→            json.field "id", actor.following_url
  1466→  1466→            json.field "totalItems", following.size
  1467→  1467→            json.field "orderedItems", following.map(&.target_id)
  1468→  1468→          end
  1469→  1469→        end)
  1470→  1470→      else
  1471→  1471→        context.response.status = HTTP::Status::NOT_FOUND
  1472→  1472→        context.response.print(%({"error": "Not found"}))
  1473→  1473→      end
  1474→  1474→
  1475→  1475→      # ============================================
  1476→  1476→      # Session endpoints: /api/session/:id/*
  1477→  1477→      # ============================================
  1478→  1478→    elsif session_id = extract_session_id(path)
  1479→  1479→      session = SESSIONS.get(session_id)
  1480→  1480→
  1481→  1481→      unless session
  1482→  1482→        context.response.status = HTTP::Status::NOT_FOUND
  1483→  1483→        context.response.content_type = CT_JSON
  1484→  1484→        context.response.print(%({"error": "Session not found"}))
  1485→  1485→        next
  1486→  1486→      end
  1487→  1487→
  1488→  1488→      subpath = path.sub("/api/session/#{session_id}", "")
  1489→  1489→
  1490→  1490→      case {subpath, method}
  1491→  1491→      # Get current location
  1492→  1492→      when {"", "GET"}, {"/", "GET"}
  1493→  1493→        context.response.content_type = CT_JSON
  1494→  1494→        if loc = session.get
  1495→  1495→          context.response.print(loc.to_json)
  1496→  1496→        else
  1497→  1497→          context.response.print(%({"active": false}))
  1498→  1498→        end
  1499→  1499→
  1500→  1500→        # Update location
  1501→  1501→      when {"", "POST"}, {"/", "POST"}
  1502→  1502→        auth = check_auth(context)
  1503→  1503→        unless auth.authenticated
  1504→  1504→          context.response.status = HTTP::Status::UNAUTHORIZED
  1505→  1505→          context.response.print(%({"error": "Unauthorized"}))
  1506→  1506→          next
  1507→  1507→        end
  1508→  1508→
  1509→  1509→        # Check owner match if session has an owner
  1510→  1510→        if session.owner_id && auth.actor
  1511→  1511→          unless session.owner_id == auth.actor.not_nil!.id
  1512→  1512→            context.response.status = HTTP::Status::FORBIDDEN
  1513→  1513→            context.response.print(%({"error": "Not session owner"}))
  1514→  1514→            next
  1515→  1515→          end
  1516→  1516→        end
  1517→  1517→
  1518→  1518→        begin
  1519→  1519→          body = context.request.body.try(&.gets_to_end) || ""
  1520→  1520→          data = JSON.parse(body)
  1521→  1521→
  1522→  1522→          loc = Location.new(
  1523→  1523→            lat: data["lat"].as_f,
  1524→  1524→            lng: data["lng"].as_f,
  1525→  1525→            accuracy: data["accuracy"].as_f,
  1526→  1526→            speed: data["speed"]?.try(&.as_f?),
  1527→  1527→            heading: data["heading"]?.try(&.as_f?),
  1528→  1528→            timestamp: Time.utc,
  1529→  1529→            active: true,
  1530→  1530→            expires: Time.utc + 30.minutes
  1531→  1531→          )
  1532→  1532→          session.update(loc)
  1533→  1533→
  1534→  1534→          context.response.content_type = CT_JSON
  1535→  1535→          context.response.print(%({"ok": true}))
  1536→  1536→        rescue ex
  1537→  1537→          context.response.status = HTTP::Status::BAD_REQUEST
  1538→  1538→          context.response.print(%({"error": "Invalid JSON"}))
  1539→  1539→        end
  1540→  1540→
  1541→  1541→        # Stop sharing
  1542→  1542→      when {"/stop", "POST"}
  1543→  1543→        auth = check_auth(context)
  1544→  1544→        unless auth.authenticated
  1545→  1545→          context.response.status = HTTP::Status::UNAUTHORIZED
  1546→  1546→          context.response.print(%({"error": "Unauthorized"}))
  1547→  1547→          next
  1548→  1548→        end
  1549→  1549→
  1550→  1550→        session.set_inactive
  1551→  1551→        context.response.content_type = CT_JSON
  1552→  1552→        context.response.print(%({"ok": true}))
  1553→  1553→
  1554→  1554→        # Delete session
  1555→  1555→      when {"/delete", "POST"}
  1556→  1556→        auth = check_auth(context)
  1557→  1557→        unless auth.authenticated
  1558→  1558→          context.response.status = HTTP::Status::UNAUTHORIZED
  1559→  1559→          context.response.print(%({"error": "Unauthorized"}))
  1560→  1560→          next
  1561→  1561→        end
  1562→  1562→
  1563→  1563→        SESSIONS.delete(session_id)
  1564→  1564→        context.response.content_type = CT_JSON
  1565→  1565→        context.response.print(%({"ok": true}))
  1566→  1566→
  1567→  1567→        # SSE stream
  1568→  1568→      when {"/stream", "GET"}
  1569→  1569→        context.response.content_type = "text/event-stream"
  1570→  1570→        context.response.headers["Cache-Control"] = "no-cache"
  1571→  1571→        context.response.headers["Connection"] = "keep-alive"
  1572→  1572→
  1573→  1573→        ch = session.subscribe
  1574→  1574→
  1575→  1575→        if loc = session.get
  1576→  1576→          context.response.print("data: #{loc.to_json}\n\n")
  1577→  1577→          context.response.flush
  1578→  1578→        end
  1579→  1579→
  1580→  1580→        spawn do
  1581→  1581→          loop do
  1582→  1582→            sleep 30.seconds
  1583→  1583→            begin
  1584→  1584→              context.response.print(": keepalive\n\n")
  1585→  1585→              context.response.flush
  1586→  1586→            rescue
  1587→  1587→              break
  1588→  1588→            end
  1589→  1589→          end
  1590→  1590→        end
  1591→  1591→
  1592→  1592→        loop do
  1593→  1593→          select
  1594→  1594→          when loc = ch.receive?
  1595→  1595→            if loc
  1596→  1596→              context.response.print("data: #{loc.to_json}\n\n")
  1597→  1597→              context.response.flush
  1598→  1598→            else
  1599→  1599→              break
  1600→  1600→            end
  1601→  1601→          end
  1602→  1602→        rescue
  1603→  1603→          break
  1604→  1604→        end
  1605→  1605→
  1606→  1606→        session.unsubscribe(ch)
  1607→  1607→
  1608→  1608→        # Subscribe to session (federation)
  1609→  1609→      when {"/subscribe", "POST"}
  1610→  1610→        remote_actor = check_http_signature(context)
  1611→  1611→        unless remote_actor
  1612→  1612→          context.response.status = HTTP::Status::UNAUTHORIZED
  1613→  1613→          context.response.print(%({"error": "Invalid signature"}))
  1614→  1614→          next
  1615→  1615→        end
  1616→  1616→
  1617→  1617→        # Check visibility permissions
  1618→  1618→        if session.visibility == SessionVisibility::FollowersOnly
  1619→  1619→          if owner_id = session.owner_id
  1620→  1620→            unless FOLLOW_STORE.is_following?(remote_actor.id, owner_id)
  1621→  1621→              context.response.status = HTTP::Status::FORBIDDEN
  1622→  1622→              context.response.print(%({"error": "Must be a follower"}))
  1623→  1623→              next
  1624→  1624→            end
  1625→  1625→          end
  1626→  1626→        end
  1627→  1627→
  1628→  1628→        session.add_remote_subscriber(remote_actor.inbox)
  1629→  1629→        context.response.status = HTTP::Status::ACCEPTED
  1630→  1630→        context.response.print(%({"ok": true}))
  1631→  1631→      else
  1632→  1632→        context.response.status = HTTP::Status::NOT_FOUND
  1633→  1633→        context.response.print(%({"error": "Not found"}))
  1634→  1634→      end
  1635→  1635→    else
  1636→  1636→      context.response.status = HTTP::Status::NOT_FOUND
  1637→  1637→      context.response.print(%({"error": "Not found"}))
  1638→  1638→    end
  1639→  1639→  end
  1640→  1640→end
  1641→  1641→
  1642→  1642→port = (ENV["PORT"]? || "8081").to_i
  1643→  1643→puts "Location relay server starting on :#{port}"
  1644→  1644→puts "Domain: #{DOMAIN}"
  1645→  1645→puts "Invite codes: #{INVITE_CODES.size} configured"
  1646→  1646→server.bind_tcp("0.0.0.0", port)
  1647→  1647→server.listen
  1648→  1648→