# Deep Links & Share Links — Mobile Integration Guide

Every link Clustr hands out — from the share sheet, from a push notification,
from a chat message — points at one of two schemes for the same screens:

```
https://letsclustr.com/posts/:id        ksn://posts/:id
https://letsclustr.com/users/:username  ksn://users/:username
```

Same paths, different scheme. **Write one parser, not two.** This doc is the
contract for the React Native client: what the URLs mean, what to configure on
each platform, how to test before any of it is verified, and what we still need
from you.

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-10** — Housekeeping only: the link site now runs on Firebase Hosting instead of
  Cloudflare Pages. Nothing you build changes — same URLs, same `ksn://` grammar, same
  previews. The only visible difference is in §9: a pre-domain test deploy shows up on a
  `*.web.app` hostname, not `*.pages.dev`, and verifying it still proves nothing about
  `letsclustr.com`.
- **2026-09-09** — First version. Share URLs now live at `letsclustr.com/posts/:id`
  and `/users/:username` (they used to be specced as `/p/:id` — that shape is
  gone). Profiles are shareable via a new `GET /api/users/:username/share`.
  Nothing here is verified yet: send us the bundle ids and signing fingerprints
  in §8 and Universal / App Links start working.

---

## Short version

```
GET  /api/users/:username/share   → { url }                       (new)
POST /api/posts/:id/share         → { url, shareCode, channel }   (unchanged, new URL shape)
```

- Never build a link yourself. Ask the API — the base URL differs per environment.
- Route `https://letsclustr.com/<path>` and `ksn://<path>` through the same handler.
- Strip and ignore `?ref=`. Keep `?commentId=`.
- Logged out on arrival? Stash the target, log in, then navigate.

---

## 1 — The URL grammar

| Screen | HTTPS | Custom scheme | Where the link comes from |
| --- | --- | --- | --- |
| Post detail | `letsclustr.com/posts/:postId` | `ksn://posts/:postId` | `POST /api/posts/:id/share`, notifications |
| Post, scrolled to a comment | `letsclustr.com/posts/:postId?commentId=:id` | `ksn://posts/:postId?commentId=:id` | comment + reply notifications |
| Profile | `letsclustr.com/users/:username` | `ksn://users/:username` | `GET /api/users/:username/share`, follow notifications |
| Conversation | — | `ksn://chat/:conversationId?messageId=:id` | chat notifications |
| Event | — | `ksn://events/:id` | event notifications |

Chat and events are **in-app only for now**. There is no
`letsclustr.com/chat/...` — the domain does not serve that path, the association
files do not claim it, and your `intent-filter` should not list it. When group
links land, all four move together.

### `?ref=` is opaque

`POST /api/posts/:id/share` appends `?ref=<10 chars>` for share attribution:

```
https://letsclustr.com/posts/019f45c6-…?ref=S6kvTbrHCR
```

Treat it as noise. Don't parse it, don't branch on it, don't send it anywhere.
`"/posts/019f45c6-…?ref=S6kvTbrHCR"` means `postId = "019f45c6-…"`, full stop.
Nothing reads `ref` today; if that changes you'll get a new endpoint, not new
parsing rules.

### Ids and usernames

`postId` is a UUIDv7. `username` matches `[a-zA-Z0-9_]` and is case-insensitive
on the server, but always arrives in canonical casing — the API builds the link
from the stored username, so `/users/BOB` will never be minted even if someone
types it.

---

## 2 — Getting a link

### Profile

```http
GET /api/users/:username/share
Authorization: Bearer <accessToken>
```

```jsonc
{ "success": true, "data": { "url": "https://letsclustr.com/users/anya_07" } }
```

| Code | When |
| --- | --- |
| `404` | No such user, account deactivated, blocked in either direction, or moderation-hidden |
| `401` | Missing/expired token |

A **private** profile is shareable and returns `200` — the recipient lands on the
locked profile with a follow button, which is the point. Only "this account
should not be discoverable" cases 404.

### Post

Unchanged, see [mobile-posts-guide §18](../post/mobile-posts-guide.md). Only the
URL shape moved: `/posts/:id`, not `/p/:id`.

**Always call the endpoint** rather than string-building
`https://letsclustr.com/posts/${id}` on the client. The base URL is server
config; a staging build must not hand out production links.

---

## 3 — Platform configuration

### iOS — Associated Domains

Add to the entitlement:

```
applinks:letsclustr.com
```

Handle incoming links in `application(_:continue:restorationHandler:)` (or the
RN `Linking` equivalent). iOS fetches
`https://letsclustr.com/.well-known/apple-app-site-association` at install time;
if it doesn't match your Team ID + bundle id, links open Safari instead — see §7.

### Android — App Links

```xml
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="https" android:host="letsclustr.com" />
  <data android:pathPrefix="/posts" />
  <data android:pathPrefix="/users" />
</intent-filter>
```

Keep the existing `ksn://` filter as a separate `<intent-filter>` — it is what
notifications and local development use, and it needs no verification.

---

## 4 — Parsing

One function, both schemes. The path is identical, so strip the scheme + host
and switch on the first segment:

```ts
// letsclustr.com/posts/abc?commentId=x  ->  { kind: "post", id: "abc", commentId: "x" }
// ksn://posts/abc?commentId=x           ->  the same
export function parseDeepLink(raw: string) {
  const u = new URL(raw);
  const [head, id] = u.pathname.replace(/^\/+/, "").split("/");
  const commentId = u.searchParams.get("commentId") ?? undefined;
  switch (head) {
    case "posts":  return id ? { kind: "post", id, commentId } : null;
    case "users":  return id ? { kind: "profile", username: id } : null;
    case "chat":   return id ? { kind: "conversation", id,
                     messageId: u.searchParams.get("messageId") ?? undefined } : null;
    case "events": return id ? { kind: "event", id } : null;
    default:       return null;   // unknown → open the app at home, never crash
  }
}
```

`ksn://posts/abc` parses as host `posts`, path `/abc` in some URL
implementations — RN's included. Normalise before splitting, or match on the raw
string. An unrecognised link must land the user on the home screen, not an error.

---

## 5 — Auth state on arrival

Three cases, and the middle one is the one that gets forgotten:

| State | Behaviour |
| --- | --- |
| Installed, logged in | Navigate straight to the target. |
| **Installed, logged out** | **Stash the parsed target, run the normal login flow, then navigate to it.** |
| Not installed | The OS opens the web fallback page (§6). |

The logged-out case is a single variable — `pendingDeepLink`, consumed once
after the first successful authenticated render — and it rescues the most common
real-world path, because the person receiving a shared link is often the one who
signed out months ago. Clear it on logout and on consumption so a stale target
can't fire later.

This is **not** deferred deep linking (install → land on the shared post). That
is deliberately out of scope: a fresh install lands on home.

---

## 6 — What the web fallback page does

When the app isn't installed, the URL opens `letsclustr.com` in a browser. That
page is static and deliberately dumb:

- An **Open in Clustr** button (fires the `ksn://` equivalent, in case the app is
  installed but the domain isn't verified yet).
- App Store and Play Store buttons.
- **No automatic redirect.** Nothing bounces to the store on its own — that
  misbehaves for desktop visitors, crawlers and preview bots.

It never shows the post or the profile. It cannot: it's a static file with no
API access.

---

## 7 — What link previews show

When a Clustr link is pasted into WhatsApp, Instagram, iMessage or anywhere else
that unfurls URLs, the metadata served is **identical for every link**:

> **Clustr** — Someone shared this with you. Open it in the Clustr app.

No post title, no body text, no photo, no username, no avatar. Clustr is a
platform for minors and those crawlers cache what they fetch indefinitely, so
nothing about the linked content leaves the app. Don't build UI that promises the
recipient a rich preview, and don't file it as a bug.

The **in-app** share sheet is different: `GET /api/posts/:id/share/preview`
returns the real title, description and image, auth-gated, for prefilling the OS
share sheet. That data never reaches the web page.

---

## 8 — What we need from you

Until these land, the association files carry `REPLACE_*` placeholders. In that
state the fallback page and link previews work and **native app association does
not work at all** — an unverified file is inert, not broken. If you test today
and links open Safari or Chrome instead of the app, that is the expected result,
not a bug to chase.

| Value | Used in |
| --- | --- |
| Apple Team ID | `apple-app-site-association` |
| iOS bundle id | `apple-app-site-association` |
| App Store id | store button + iOS smart banner |
| Android package name | `assetlinks.json` + store button |
| Release SHA-256 fingerprint | `assetlinks.json` |
| Play App Signing SHA-256 fingerprint | `assetlinks.json` |
| Debug SHA-256 fingerprint | `assetlinks.json` (so debug builds verify too) |

---

## 9 — Testing

### Locally, today

Universal Links and Android App Links **cannot** work against `localhost` — both
require HTTPS on a domain serving a verified association file. So local link
testing uses `ksn://`, which needs no domain, no TLS and no verification:

```bash
xcrun simctl openurl booted "ksn://posts/019f45c6-0000-7000-8000-000000000000"
xcrun simctl openurl booted "ksn://users/anya_07"

adb shell am start -a android.intent.action.VIEW -d "ksn://posts/019f45c6-…"
```

Exercise the logged-out path too — sign out first, then fire the same command.

### HTTPS, once identifiers are in

```bash
# should open the app, not the browser
adb shell am start -a android.intent.action.VIEW -d "https://letsclustr.com/posts/<id>"
adb shell pm verify-app-links --re-verify <package>
adb shell pm get-app-links <package>          # expect: verified
```

On iOS, tap a link from Notes or Messages — not from Safari's address bar, which
deliberately bypasses Universal Links. Apple's AASA validator reads the
production domain.

> A green check on any `*.web.app` preview hostname means nothing. Association
> files are per-domain; only `letsclustr.com` counts.

---

## 10 — Checklist

- [ ] `applinks:letsclustr.com` entitlement (iOS)
- [ ] `autoVerify` intent-filter for `/posts` + `/users` (Android)
- [ ] `ksn://` intent-filter kept alongside it
- [ ] One `parseDeepLink` handling both schemes
- [ ] Unknown path → home screen, no crash
- [ ] `?ref=` ignored, `?commentId=` honoured
- [ ] `pendingDeepLink` survives login, cleared on logout
- [ ] Share button calls the API for the URL instead of building it
- [ ] Identifiers from §8 sent to backend
