commits
Made-with: Cursor
Root cause: bluesky-social/social-app classifies pasted URLs with a
client-side `getLikelyType` BEFORE calling Cardyb. It splits the URL
path by `.`, takes the last segment, and looks it up in a baked-in
MIME-type table. For atproto handles like `foo.com` the segment is
`com`, mapped to `application/x-msdownload` (a Windows executable).
The composer therefore returns LikelyType.Other, never calls Cardyb,
and shows a link card without an image.
Source:
https://github.com/bluesky-social/social-app/blob/main/src/lib/link-meta/link-meta.ts
Fix: append `/` to project page share URLs. The composer now sees
extension `com/`, falls through to LikelyType.HTML, calls Cardyb
extract, and unfurls the page (Cardyb follows our 308 to the
canonical no-slash URL and reads the OG meta tags as expected).
This is purely a share-URL change; the canonical Fresh route stays
at `/explore/[handle]`, and `/explore/[handle]/` continues to 308.
Made-with: Cursor
Project pages now set og:image to `/api/registry/project-og/{handle}` instead
of og-banner with an encoded DID, so Cardyb’s nested URL encoding stays
simpler and matches patterns that unfurl reliably.
- Add `lib/og-banner-serve.ts` with shared `buildOgBannerResponse`, exact
ArrayBuffer slicing for Content-Length bodies, JPEG magic validation on
DB cache (invalid rows fall back to PDS resize + overwrite)
- Store tight `Uint8Array` copies (`.slice()`) before writing og_jpeg to Turso
- Keep `/api/registry/og-banner/{did}` as a thin wrapper for compatibility
Made-with: Cursor
The root cause of the Bluesky link card image not appearing was response
latency. The og-banner route was fetching the full-resolution blob from the
PDS and running ImageScript decode+resize+encode on every request. The first
(uncached) hit could take 3–5 s; Cardyb's timeout is shorter than that, so it
returned "Unable to serve image" and cached the failure.
Fix: generate and store the 1200×630 JPEG in a new `og_jpeg` BLOB column at
profile-save time. The og-banner route now returns the cached bytes in < 10 ms
(a single DB SELECT), identical in latency profile to serving a static file.
- lib/db.ts: add `og_jpeg BLOB` column + additive migration
- lib/registry.ts: add `ogJpeg` to UpsertProfileInput; new storeOgJpeg /
getOgJpeg helpers; ON CONFLICT preserves existing og_jpeg when not replaced
- routes/api/registry/profile.ts: run generateOgJpeg() on banner upload bytes
and pass result to upsertProfile
- routes/api/registry/og-banner/[did].ts: fast path returns DB-cached JPEG;
slow path (pre-feature profiles) fetches PDS blob, resizes, stores to DB,
returns result so next request is fast
- routes/api/admin/backfill-og-jpegs.ts: POST endpoint to backfill og_jpeg
for existing profiles that pre-date this feature
Made-with: Cursor
og-hero.png (static file, works) returns Content-Length: 317687.
og-banner (dynamic route, broken) had no Content-Length — Deno Deploy
does not set it for Blob bodies on dynamic routes. Bluesky's image upload
pipeline requires Content-Length to process external card thumbnails;
without it the image is silently rejected and the card shows text-only.
Use ArrayBuffer body + explicit content-length header so the og-banner
response matches the header profile of a static image file.
Made-with: Cursor
Some clients (including possible Bluesky composer fetch paths) validate
og:image via cross-origin fetch rather than plain <img>. Add
Access-Control-Allow-Origin: *, Cross-Origin-Resource-Policy: cross-origin,
and Content-Disposition: inline on /api/registry/banner and /og-banner.
Made-with: Cursor
Reuses explore project-page share copy and the canonical /explore/{handle}
URL. Hidden until a published registry handle exists (not takedown).
Made-with: Cursor
Cardyb could preview full PNG banners while the Bluesky composer still
omitted thumbnails—likely size/format limits on the external thumb
pipeline. Add /api/registry/og-banner/{did} that decodes the PDS banner,
center-crops to 1.91:1, and emits a small JPEG; project pages use it for
og:image while keeping full-resolution /api/registry/banner for <img>.
ImageScript handles PNG/WebP/JPEG decode; on resize failure, serve raw bytes.
Made-with: Cursor
Homepage link cards used /og-hero.png (~320KB, atmosphereaccount.com).
Project pages used full-size cdn.bsky.app banner URLs (~650KB+), which
matched Cardyb extract but often showed text-only cards in the Bluesky
composer. Use absolute /api/registry/banner/{did} for og/twitter image
metadata so embeds follow the same origin/size pattern as the homepage.
Remove unused bsky CDN banner helpers from lib/avatar.ts.
Made-with: Cursor
Trailing slash on project URLs (e.g. /explore/handle/) returned 404, so
Bluesky Cardyb and other unfurlers saw no HTML and produced empty cards.
Add middleware to 308-redirect GET/HEAD to the canonical path without a
trailing slash.
Project pages: set og:url + link rel=canonical to the share URL, use
og:type website for broader parser compatibility, and emit twitter:title /
twitter:description alongside existing Twitter card image tags.
Made-with: Cursor
Bluesky's link-preview crawler (cardyb.bsky.app) only proxies og:images
from its own allowlisted domains. When the project's PDS is on Bluesky
(bsky.social / *.bsky.network), use cdn.bsky.app directly for og:image
so Bluesky can actually retrieve and display the embed card image.
Custom-PDS accounts continue to use our own banner proxy endpoint.
Also adds @png/@jpeg format suffix to bskyCdnBannerUrl (required by
Bluesky's CDN for correct content-type negotiation).
Made-with: Cursor
Made-with: Cursor
socialImageUrl prepended FRESH_PUBLIC_SITE_URL even when the input was
already an absolute https:// URL, producing a broken double-prefixed OG
image URL that crawlers (Bluesky, Slack, etc.) could not resolve.
Fixes:
- socialImageUrl now returns early if the path already starts with http(s)://
- Simplify [handle].tsx to pass a root-relative path for ogImageUrl so
socialImageUrl correctly makes it absolute in all environments
- Add og:image:secure_url + og:image:type meta tags (Bluesky prefers these)
- Pass imageType, imageWidth, imageHeight explicitly from project banner
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
Renames the public profile detail page to "project page" in user-facing
copy and wires up a project banner that doubles as the link-card image
when the page URL is shared.
- Lexicon: add optional `banner` blob to com.atmosphereaccount.registry.profile
(PNG/JPEG/WebP, 3MB, recommended 1200x630 — matches OG card ratio).
- DB: additive banner_cid / banner_mime columns on profile + matching
read/write paths in lib/registry.ts and lib/profile-sync.ts.
- Profile API: accept banner blob refs and base64 uploads via PUT
/api/registry/profile, mirroring the avatar contract.
- Form: new banner upload slot in CreateProfileForm (above the avatar +
fields row) with preview, replace, and remove controls.
- Banner endpoint: /api/registry/banner/<did> redirects to the Bluesky
CDN banner URL, mirroring the avatar compatibility route.
- Project page: render banner at the top of /explore/<handle>, set
per-page OG/Twitter meta via state.pageMeta so the banner becomes the
link-card image (with project name + description in the card text).
- Share: new ShareButton island with Web Share API on platforms that
support it and copy-to-clipboard fallback elsewhere.
- Copy: rename "View profile" / "View public profile" / "Edit this
profile" to project-page wording where the target is a project page.
Lexicon JSON is updated in-repo only — publish via `goat lex publish
--update` when ready. Not pushed.
Made-with: Cursor
Account menu:
- When the session is cleared (e.g. after /oauth/add-account redirects
to the sign-in page) but the device still has remembered accounts,
the account menu now shows a signed-out dropdown instead of a plain
"Sign in" link. The dropdown lists all previously-authenticated
accounts as one-click switch targets, so the user's personal account
stays visible and accessible throughout the project account sign-in.
- Fully signed-out devices with no remembered accounts are unchanged.
OAuth scope / permissions text:
- Prepend com.atmosphereaccount.registry.fullPermissions to the scope
string. This named permission set (title: "Atmosphere Account",
detail: "Manage your Atmosphere explore profile, reviews, and
updates.") is what the PDS consent screen uses to render the branded
label rather than raw NSID strings. The explicit repo:* scopes are
kept alongside it so sign-in never fails on PDSes that cannot resolve
the DNS-backed permission set.
Made-with: Cursor
New sign-in flow:
- Default sign-in (header button, review-page CTA) → user account, no
chooser shown; user lands on /account/reviews dashboard.
- "Submit your project" CTA → carries intent=project through OAuth;
freshly-signed-in DID is auto-classified as a project and lands on
/explore/manage.
User → project upgrade (for accounts already signed in as users):
- A small "Submit your project" button appears at the top of the
/account/reviews dashboard.
- Clicking it opens a modal: "Is this account a project?"
- "Yes" → POSTs to /api/account/type, converts to project, redirects
to /explore/manage.
- A link lets users sign in with a different (project) account via
/oauth/add-account?intent=project, so the next sign-in is also
auto-classified as a project.
Removed:
- /account/type chooser page (replaced with a smart redirect for legacy
bookmarks/in-flight sessions; untyped DIDs default to user).
- requiresAccountTypeChoice helper (no longer needed).
Made-with: Cursor
Use createPortal to render the review modal at document.body, escaping
any parent stacking contexts that were clipping or obscuring it.
Co-authored-by: pdewey.com <pdewey.com>
Made-with: Cursor
Authorization servers fetch these URLs cross-origin during PAR. Without
Access-Control-Allow-Origin, some AS implementations reject the client with
invalid_client_metadata (Unable to obtain client metadata).
Made-with: Cursor
- Show AccountMenu on /developer-resources with buildAccountMenuProps
- Add #project-icons anchor and support ?icon=&variant= in SvgIconDownloads
- Add manage form link to developer resources; PUT profile returns icon refs
- Version icon proxy URLs with ?v=cid to avoid stale browser/CDN caches
- US spelling and OG alt copy tweaks
Made-with: Cursor
The registry API isn't publicly available yet, so drop the mention
from the preview image. Subtitle now reads 'Tools to make the
Atmosphere easier to understand.'
Made-with: Cursor
Projects can upload a second black-and-white SVG (same gate, sanitizer,
and size limits as the colour mark). Persist iconBw in the lexicon,
registry DB, profile sync, and public JSON (iconBwUrl). Serve it at
/api/registry/icon-bw/:did; include both variants in the dev ZIP and
list API. Manage form shows parallel upload slots; developer resources
grid has a Colour/B/W toggle for preview and per-variant downloads.
Fix TypeScript narrowing in processIconVariant by capturing did/pdsUrl
after auth checks.
Made-with: Cursor
Improve perceived navigation speed with lightweight skeletons and hide screenshot carousel controls when there is only one image.
Made-with: Cursor
Avoid permission-set resolution during OAuth and make profile indexing tolerant of records created outside the Atmosphere client.
Made-with: Cursor
Keep the lightweight scrolled nav behavior available outside the homepage while leaving sky effects disabled.
Made-with: Cursor
Regenerate og-explore from og-hero layout with Explore headline and
Atmosphere subtext. Regenerate PNG for embeds.
Made-with: Cursor
Use an Explore-specific Open Graph image for registry pages so Bluesky embeds reflect the app directory rather than the homepage.
Made-with: Cursor
Move the latest update preview outside the closed details element so the short excerpt is visible before expanding the full update.
Made-with: Cursor
Resolve remembered-account avatars from user profile rows as well as project registry rows so the account switcher keeps showing avatars after CDN redirects.
Made-with: Cursor
Show a concise two-line latest update preview before expanding to the full text.
Made-with: Cursor
Use direct did/cid CDN URLs for primary avatar paths and keep the app avatar endpoint as a compatibility redirect to avoid proxying image bytes through the app.
Made-with: Cursor
Keep What's New below reviews while moving version history into a popup and aligning the profile card heading and review card styling.
Made-with: Cursor
Add project-owned What's New records with local indexing and owner/public UI so update history can live on the project PDS while rendering quickly in Explore.
Made-with: Cursor
Publish user profiles and reviews as registry protocol records while keeping project discovery indexed separately for Explore and moderation.
Made-with: Cursor
Limit the OAuth permission set to project profile writes so regular users are not asked for featured-directory write access.
Made-with: Cursor
Split signed-in accounts into user and project profile paths so reviewers can manage their reviews without creating project listings, and keep non-home pages free of sky effects for better performance.
Made-with: Cursor
Separate user accounts from project registry profiles so reviewers can manage their reviews without creating public project profiles.
Made-with: Cursor
Adds detail-page screenshots, review/report flows, and simpler public category badges so profile pages can present richer project context.
Made-with: Cursor
Move primary app destinations into the profile hero card and keep Atmosphere links as compact secondary actions.
Made-with: Cursor
Allow registry profiles to save without a description and avoid rendering empty description blocks on public profile surfaces.
Made-with: Cursor
Also refreshes the developer resources assets and keeps local Fresh island hydration working during dev.
Made-with: Cursor
- Use tangled.org for default Tangled links and README (was tangled.sh)
- Add lib/public-profile.ts (toPublicProfileJson) for read APIs
- Strip moderation, verification workflow, and raw icon CIDs from
GET /api/registry/profile, /search, /featured; add verified boolean
- Update developer-resources API copy
Made-with: Cursor
- Add HMAC-signed atmo_accounts cookie and rememberedAccounts on session state
- OAuth callback appends signed-in identity; add /oauth/switch, /forget, /add-account
- AccountMenu: switch between remembered accounts, forget, add account; DID-keyed avatar
- buildAccountMenuProps: /api/me/avatar?v=<did> so browser cache does not mix users
- Wire Nav account props across explore and admin pages; i18n + styles
Made-with: Cursor
- Move every field below name/description out of the avatar+fields
right column into a new full-width .profile-form-stack so chip
groups, Atmosphere services, and link editors get the whole card
on desktop.
- Drop the duplicate "Signed in as @handle / Sign out" lockup above
the card; the handle row inside the form now anchors a sign-out
pill on the right (button uses formAction="/oauth/logout" +
formNoValidate so it doesn't trip the parent profile form).
- Center the Update / View / Remove action row inside the card.
- Convert Custom links + Developer icon sections from
fieldset/legend to div/span so the .profile-form-field flex gap
finally applies between the label and its content (legend doesn't
participate in flex layout). Drop the now-redundant
.custom-link-list margin-top + custom-link-add margin-top.
Made-with: Cursor
Reframes the icon-access gate as project verification. Verifying a
project does two things: drops the new starburst checkmark seal next
to the project name on listing cards and the detail-page hero, and
unlocks SVG icon uploads for the developer API. Copy throughout the
form, modal, admin overview, and admin/icon-access page is updated to
match — "SVG icon verification" is just "Verification" everywhere.
- new components/VerifiedBadge.tsx renders the seal as inline SVG
with currentColor; brand-blue (#254a9e) by default via CSS
- ProfileHero (22px) and ProfileCard (16px) show the seal when
profile.iconAccessStatus === "granted"
- i18n: rename gate / modal / admin strings to drop SVG framing,
add badges.verifiedTooltip, switch admin actions to Verify / Verified
- /api/registry/profile error detail rephrased
Made-with: Cursor
Projects can no longer upload SVG icons until an admin grants their
project access. This replaces the per-icon moderation queue with a
one-time per-project verification:
- profile gains icon_access_status (granted | requested | denied) plus
email/timestamp/reviewer/denied_reason columns (additive migration)
- POST /api/registry/icon-access/request lets a signed-in owner submit
a contact email; PUT /api/registry/profile rejects icon blobs from
non-granted projects
- public icon serving and the public profile API gate iconUrl on both
icon_status === approved AND icon_access_status === granted
- new admin page /admin/icon-access with grant / deny / revoke actions
(deny doubles as revoke); old /admin/icons + per-icon endpoints +
AdminIconReview island removed
- CreateProfileForm renders a locked / pending / denied / granted
banner and a Request Verification modal for collecting the contact
email; appeals routed to contact@atmosphereaccount.com
- minor: replace window.prompt with globalThis.prompt to satisfy
deno lint
Made-with: Cursor
Listing card click goes back to /explore/<handle> with no arrow —
visitors land on the detail page first, where they can read the
description, see the Atmosphere services and landing page, then
choose to open the project. The diagonal-arrow button-affordance
now lives on the detail-page hero card itself: when a profile has
mainLink the whole hero becomes <a target="_blank"> and the corner
disc lights up brand-blue on hover. Records without mainLink keep
the hero as a static panel so the layout doesn't visibly downgrade.
Also: replace the homepage explore CTA copy with the shorter
"Browse apps and services in the Atmosphere." line.
Made-with: Cursor
The whole /explore profile card is now a button: clicking it sends
visitors to the project's mainLink (the actual app, service, or
landing page) with an external-link arrow in the top-right corner.
The previous "Website" concept becomes an optional secondary
"Landing Page" button on the profile detail page.
Lexicon
* com.atmosphereaccount.registry.profile: add optional mainLink
string (uri, <=512). Optional in the lexicon for backward-compat
reads of pre-mainLink records; the registry UI/API enforce it as
required for new/updated records.
* The legacy `website` link kind is repurposed as the storage shape
for the optional Landing Page button (no schema rename — keeps
existing records valid).
DB
* profile.main_link TEXT column (additive migration; safe on
existing Turso DBs).
* upsertProfile reads/writes main_link; ON CONFLICT preserves the
same admin/icon semantics as before.
Form (/explore/manage)
* New required "Main Link (URL)" field directly above Atmosphere
links.
* Legacy `kind: website` URL is auto-promoted into the Main Link
slot on first load when the record has no mainLink — the user
agreed to "treat existing website as Main Link" semantics so this
is one save away from clean.
* Renamed Website field to "Landing Page (URL)" with a hint
clarifying it's a separate marketing/landing surface.
* Client-side guard: empty / non-http(s) Main Link surfaces a clean
inline error before round-tripping.
API (PUT /api/registry/profile)
* mainLink is required server-side; rejects with 400 + a clear
error otherwise. Validation library still treats mainLink as
optional so reads of pre-mainLink records keep parsing.
Public surfaces
* ProfileCard: whole card is an anchor to mainLink (target=_blank,
rel="noopener noreferrer external"), with a small inline SVG
arrow that lifts on hover. Falls back to /explore/<handle> for
legacy records that lack mainLink so the card never 404s.
* ProfileLinks already filters out empty entries, so missing
Atmosphere links / missing Landing Page just don't render — no
empty placeholder buttons.
* Globe icon + "Landing page" label propagate via the existing
resolveLink path; no detail-page changes needed.
i18n
* Added forms.profile.mainLink + forms.profile.landingPage; kept
the legacy bskyDescription strings around for now (used elsewhere).
* linkKinds.website re-labelled "Landing page" so the public button
reads correctly without renaming the underlying kind.
Migration story
* Existing records keep working unchanged. On first re-save through
the form, mainLink lands on the record and the legacy website
entry drops off (the form leaves Landing Page empty post-promotion
so users don't end up with two buttons pointing at the same URL).
Made-with: Cursor
* Permission set now grants repo:write on both
com.atmosphereaccount.registry.profile AND
com.atmosphereaccount.registry.featured. Without the second, the
curator's existing OAuth token couldn't putRecord featured/self
from the new /admin/featured editor (PDS replied with
ScopeMissingError on com.atmosphereaccount.registry.featured?action=create).
Requires republishing the lexicon (deno task lex:publish:update) and
the curator re-authenticating so the issued token resolves the new
permission set.
* Apply explore-no-effects on /admin/* the same way we already do for
/explore/* and /developer-resources — the sky toggle and animation
are noise on the operator surface.
Made-with: Cursor
CREATE INDEX IF NOT EXISTS profile_takedown ON profile(takedown_status)
ran inside SCHEMA_STATEMENTS, before applyAdditiveMigrations had a
chance to ALTER TABLE in the new takedown_status column on existing
databases. The index creation aborted with "no such column:
takedown_status", which short-circuited migrate() entirely and broke
every withDb() caller (visible as a SQL_INPUT_ERROR during login).
Move column-dependent indexes into POST_MIGRATION_INDEX_STATEMENTS,
applied after applyAdditiveMigrations so pre-existing tables gain the
column first.
Made-with: Cursor
Root cause: bluesky-social/social-app classifies pasted URLs with a
client-side `getLikelyType` BEFORE calling Cardyb. It splits the URL
path by `.`, takes the last segment, and looks it up in a baked-in
MIME-type table. For atproto handles like `foo.com` the segment is
`com`, mapped to `application/x-msdownload` (a Windows executable).
The composer therefore returns LikelyType.Other, never calls Cardyb,
and shows a link card without an image.
Source:
https://github.com/bluesky-social/social-app/blob/main/src/lib/link-meta/link-meta.ts
Fix: append `/` to project page share URLs. The composer now sees
extension `com/`, falls through to LikelyType.HTML, calls Cardyb
extract, and unfurls the page (Cardyb follows our 308 to the
canonical no-slash URL and reads the OG meta tags as expected).
This is purely a share-URL change; the canonical Fresh route stays
at `/explore/[handle]`, and `/explore/[handle]/` continues to 308.
Made-with: Cursor
Project pages now set og:image to `/api/registry/project-og/{handle}` instead
of og-banner with an encoded DID, so Cardyb’s nested URL encoding stays
simpler and matches patterns that unfurl reliably.
- Add `lib/og-banner-serve.ts` with shared `buildOgBannerResponse`, exact
ArrayBuffer slicing for Content-Length bodies, JPEG magic validation on
DB cache (invalid rows fall back to PDS resize + overwrite)
- Store tight `Uint8Array` copies (`.slice()`) before writing og_jpeg to Turso
- Keep `/api/registry/og-banner/{did}` as a thin wrapper for compatibility
Made-with: Cursor
The root cause of the Bluesky link card image not appearing was response
latency. The og-banner route was fetching the full-resolution blob from the
PDS and running ImageScript decode+resize+encode on every request. The first
(uncached) hit could take 3–5 s; Cardyb's timeout is shorter than that, so it
returned "Unable to serve image" and cached the failure.
Fix: generate and store the 1200×630 JPEG in a new `og_jpeg` BLOB column at
profile-save time. The og-banner route now returns the cached bytes in < 10 ms
(a single DB SELECT), identical in latency profile to serving a static file.
- lib/db.ts: add `og_jpeg BLOB` column + additive migration
- lib/registry.ts: add `ogJpeg` to UpsertProfileInput; new storeOgJpeg /
getOgJpeg helpers; ON CONFLICT preserves existing og_jpeg when not replaced
- routes/api/registry/profile.ts: run generateOgJpeg() on banner upload bytes
and pass result to upsertProfile
- routes/api/registry/og-banner/[did].ts: fast path returns DB-cached JPEG;
slow path (pre-feature profiles) fetches PDS blob, resizes, stores to DB,
returns result so next request is fast
- routes/api/admin/backfill-og-jpegs.ts: POST endpoint to backfill og_jpeg
for existing profiles that pre-date this feature
Made-with: Cursor
og-hero.png (static file, works) returns Content-Length: 317687.
og-banner (dynamic route, broken) had no Content-Length — Deno Deploy
does not set it for Blob bodies on dynamic routes. Bluesky's image upload
pipeline requires Content-Length to process external card thumbnails;
without it the image is silently rejected and the card shows text-only.
Use ArrayBuffer body + explicit content-length header so the og-banner
response matches the header profile of a static image file.
Made-with: Cursor
Cardyb could preview full PNG banners while the Bluesky composer still
omitted thumbnails—likely size/format limits on the external thumb
pipeline. Add /api/registry/og-banner/{did} that decodes the PDS banner,
center-crops to 1.91:1, and emits a small JPEG; project pages use it for
og:image while keeping full-resolution /api/registry/banner for <img>.
ImageScript handles PNG/WebP/JPEG decode; on resize failure, serve raw bytes.
Made-with: Cursor
Homepage link cards used /og-hero.png (~320KB, atmosphereaccount.com).
Project pages used full-size cdn.bsky.app banner URLs (~650KB+), which
matched Cardyb extract but often showed text-only cards in the Bluesky
composer. Use absolute /api/registry/banner/{did} for og/twitter image
metadata so embeds follow the same origin/size pattern as the homepage.
Remove unused bsky CDN banner helpers from lib/avatar.ts.
Made-with: Cursor
Trailing slash on project URLs (e.g. /explore/handle/) returned 404, so
Bluesky Cardyb and other unfurlers saw no HTML and produced empty cards.
Add middleware to 308-redirect GET/HEAD to the canonical path without a
trailing slash.
Project pages: set og:url + link rel=canonical to the share URL, use
og:type website for broader parser compatibility, and emit twitter:title /
twitter:description alongside existing Twitter card image tags.
Made-with: Cursor
Bluesky's link-preview crawler (cardyb.bsky.app) only proxies og:images
from its own allowlisted domains. When the project's PDS is on Bluesky
(bsky.social / *.bsky.network), use cdn.bsky.app directly for og:image
so Bluesky can actually retrieve and display the embed card image.
Custom-PDS accounts continue to use our own banner proxy endpoint.
Also adds @png/@jpeg format suffix to bskyCdnBannerUrl (required by
Bluesky's CDN for correct content-type negotiation).
Made-with: Cursor
socialImageUrl prepended FRESH_PUBLIC_SITE_URL even when the input was
already an absolute https:// URL, producing a broken double-prefixed OG
image URL that crawlers (Bluesky, Slack, etc.) could not resolve.
Fixes:
- socialImageUrl now returns early if the path already starts with http(s)://
- Simplify [handle].tsx to pass a root-relative path for ogImageUrl so
socialImageUrl correctly makes it absolute in all environments
- Add og:image:secure_url + og:image:type meta tags (Bluesky prefers these)
- Pass imageType, imageWidth, imageHeight explicitly from project banner
Made-with: Cursor
Renames the public profile detail page to "project page" in user-facing
copy and wires up a project banner that doubles as the link-card image
when the page URL is shared.
- Lexicon: add optional `banner` blob to com.atmosphereaccount.registry.profile
(PNG/JPEG/WebP, 3MB, recommended 1200x630 — matches OG card ratio).
- DB: additive banner_cid / banner_mime columns on profile + matching
read/write paths in lib/registry.ts and lib/profile-sync.ts.
- Profile API: accept banner blob refs and base64 uploads via PUT
/api/registry/profile, mirroring the avatar contract.
- Form: new banner upload slot in CreateProfileForm (above the avatar +
fields row) with preview, replace, and remove controls.
- Banner endpoint: /api/registry/banner/<did> redirects to the Bluesky
CDN banner URL, mirroring the avatar compatibility route.
- Project page: render banner at the top of /explore/<handle>, set
per-page OG/Twitter meta via state.pageMeta so the banner becomes the
link-card image (with project name + description in the card text).
- Share: new ShareButton island with Web Share API on platforms that
support it and copy-to-clipboard fallback elsewhere.
- Copy: rename "View profile" / "View public profile" / "Edit this
profile" to project-page wording where the target is a project page.
Lexicon JSON is updated in-repo only — publish via `goat lex publish
--update` when ready. Not pushed.
Made-with: Cursor
Account menu:
- When the session is cleared (e.g. after /oauth/add-account redirects
to the sign-in page) but the device still has remembered accounts,
the account menu now shows a signed-out dropdown instead of a plain
"Sign in" link. The dropdown lists all previously-authenticated
accounts as one-click switch targets, so the user's personal account
stays visible and accessible throughout the project account sign-in.
- Fully signed-out devices with no remembered accounts are unchanged.
OAuth scope / permissions text:
- Prepend com.atmosphereaccount.registry.fullPermissions to the scope
string. This named permission set (title: "Atmosphere Account",
detail: "Manage your Atmosphere explore profile, reviews, and
updates.") is what the PDS consent screen uses to render the branded
label rather than raw NSID strings. The explicit repo:* scopes are
kept alongside it so sign-in never fails on PDSes that cannot resolve
the DNS-backed permission set.
Made-with: Cursor
New sign-in flow:
- Default sign-in (header button, review-page CTA) → user account, no
chooser shown; user lands on /account/reviews dashboard.
- "Submit your project" CTA → carries intent=project through OAuth;
freshly-signed-in DID is auto-classified as a project and lands on
/explore/manage.
User → project upgrade (for accounts already signed in as users):
- A small "Submit your project" button appears at the top of the
/account/reviews dashboard.
- Clicking it opens a modal: "Is this account a project?"
- "Yes" → POSTs to /api/account/type, converts to project, redirects
to /explore/manage.
- A link lets users sign in with a different (project) account via
/oauth/add-account?intent=project, so the next sign-in is also
auto-classified as a project.
Removed:
- /account/type chooser page (replaced with a smart redirect for legacy
bookmarks/in-flight sessions; untyped DIDs default to user).
- requiresAccountTypeChoice helper (no longer needed).
Made-with: Cursor
- Show AccountMenu on /developer-resources with buildAccountMenuProps
- Add #project-icons anchor and support ?icon=&variant= in SvgIconDownloads
- Add manage form link to developer resources; PUT profile returns icon refs
- Version icon proxy URLs with ?v=cid to avoid stale browser/CDN caches
- US spelling and OG alt copy tweaks
Made-with: Cursor
Projects can upload a second black-and-white SVG (same gate, sanitizer,
and size limits as the colour mark). Persist iconBw in the lexicon,
registry DB, profile sync, and public JSON (iconBwUrl). Serve it at
/api/registry/icon-bw/:did; include both variants in the dev ZIP and
list API. Manage form shows parallel upload slots; developer resources
grid has a Colour/B/W toggle for preview and per-variant downloads.
Fix TypeScript narrowing in processIconVariant by capturing did/pdsUrl
after auth checks.
Made-with: Cursor
- Use tangled.org for default Tangled links and README (was tangled.sh)
- Add lib/public-profile.ts (toPublicProfileJson) for read APIs
- Strip moderation, verification workflow, and raw icon CIDs from
GET /api/registry/profile, /search, /featured; add verified boolean
- Update developer-resources API copy
Made-with: Cursor
- Add HMAC-signed atmo_accounts cookie and rememberedAccounts on session state
- OAuth callback appends signed-in identity; add /oauth/switch, /forget, /add-account
- AccountMenu: switch between remembered accounts, forget, add account; DID-keyed avatar
- buildAccountMenuProps: /api/me/avatar?v=<did> so browser cache does not mix users
- Wire Nav account props across explore and admin pages; i18n + styles
Made-with: Cursor
- Move every field below name/description out of the avatar+fields
right column into a new full-width .profile-form-stack so chip
groups, Atmosphere services, and link editors get the whole card
on desktop.
- Drop the duplicate "Signed in as @handle / Sign out" lockup above
the card; the handle row inside the form now anchors a sign-out
pill on the right (button uses formAction="/oauth/logout" +
formNoValidate so it doesn't trip the parent profile form).
- Center the Update / View / Remove action row inside the card.
- Convert Custom links + Developer icon sections from
fieldset/legend to div/span so the .profile-form-field flex gap
finally applies between the label and its content (legend doesn't
participate in flex layout). Drop the now-redundant
.custom-link-list margin-top + custom-link-add margin-top.
Made-with: Cursor
Reframes the icon-access gate as project verification. Verifying a
project does two things: drops the new starburst checkmark seal next
to the project name on listing cards and the detail-page hero, and
unlocks SVG icon uploads for the developer API. Copy throughout the
form, modal, admin overview, and admin/icon-access page is updated to
match — "SVG icon verification" is just "Verification" everywhere.
- new components/VerifiedBadge.tsx renders the seal as inline SVG
with currentColor; brand-blue (#254a9e) by default via CSS
- ProfileHero (22px) and ProfileCard (16px) show the seal when
profile.iconAccessStatus === "granted"
- i18n: rename gate / modal / admin strings to drop SVG framing,
add badges.verifiedTooltip, switch admin actions to Verify / Verified
- /api/registry/profile error detail rephrased
Made-with: Cursor
Projects can no longer upload SVG icons until an admin grants their
project access. This replaces the per-icon moderation queue with a
one-time per-project verification:
- profile gains icon_access_status (granted | requested | denied) plus
email/timestamp/reviewer/denied_reason columns (additive migration)
- POST /api/registry/icon-access/request lets a signed-in owner submit
a contact email; PUT /api/registry/profile rejects icon blobs from
non-granted projects
- public icon serving and the public profile API gate iconUrl on both
icon_status === approved AND icon_access_status === granted
- new admin page /admin/icon-access with grant / deny / revoke actions
(deny doubles as revoke); old /admin/icons + per-icon endpoints +
AdminIconReview island removed
- CreateProfileForm renders a locked / pending / denied / granted
banner and a Request Verification modal for collecting the contact
email; appeals routed to contact@atmosphereaccount.com
- minor: replace window.prompt with globalThis.prompt to satisfy
deno lint
Made-with: Cursor
Listing card click goes back to /explore/<handle> with no arrow —
visitors land on the detail page first, where they can read the
description, see the Atmosphere services and landing page, then
choose to open the project. The diagonal-arrow button-affordance
now lives on the detail-page hero card itself: when a profile has
mainLink the whole hero becomes <a target="_blank"> and the corner
disc lights up brand-blue on hover. Records without mainLink keep
the hero as a static panel so the layout doesn't visibly downgrade.
Also: replace the homepage explore CTA copy with the shorter
"Browse apps and services in the Atmosphere." line.
Made-with: Cursor
The whole /explore profile card is now a button: clicking it sends
visitors to the project's mainLink (the actual app, service, or
landing page) with an external-link arrow in the top-right corner.
The previous "Website" concept becomes an optional secondary
"Landing Page" button on the profile detail page.
Lexicon
* com.atmosphereaccount.registry.profile: add optional mainLink
string (uri, <=512). Optional in the lexicon for backward-compat
reads of pre-mainLink records; the registry UI/API enforce it as
required for new/updated records.
* The legacy `website` link kind is repurposed as the storage shape
for the optional Landing Page button (no schema rename — keeps
existing records valid).
DB
* profile.main_link TEXT column (additive migration; safe on
existing Turso DBs).
* upsertProfile reads/writes main_link; ON CONFLICT preserves the
same admin/icon semantics as before.
Form (/explore/manage)
* New required "Main Link (URL)" field directly above Atmosphere
links.
* Legacy `kind: website` URL is auto-promoted into the Main Link
slot on first load when the record has no mainLink — the user
agreed to "treat existing website as Main Link" semantics so this
is one save away from clean.
* Renamed Website field to "Landing Page (URL)" with a hint
clarifying it's a separate marketing/landing surface.
* Client-side guard: empty / non-http(s) Main Link surfaces a clean
inline error before round-tripping.
API (PUT /api/registry/profile)
* mainLink is required server-side; rejects with 400 + a clear
error otherwise. Validation library still treats mainLink as
optional so reads of pre-mainLink records keep parsing.
Public surfaces
* ProfileCard: whole card is an anchor to mainLink (target=_blank,
rel="noopener noreferrer external"), with a small inline SVG
arrow that lifts on hover. Falls back to /explore/<handle> for
legacy records that lack mainLink so the card never 404s.
* ProfileLinks already filters out empty entries, so missing
Atmosphere links / missing Landing Page just don't render — no
empty placeholder buttons.
* Globe icon + "Landing page" label propagate via the existing
resolveLink path; no detail-page changes needed.
i18n
* Added forms.profile.mainLink + forms.profile.landingPage; kept
the legacy bskyDescription strings around for now (used elsewhere).
* linkKinds.website re-labelled "Landing page" so the public button
reads correctly without renaming the underlying kind.
Migration story
* Existing records keep working unchanged. On first re-save through
the form, mainLink lands on the record and the legacy website
entry drops off (the form leaves Landing Page empty post-promotion
so users don't end up with two buttons pointing at the same URL).
Made-with: Cursor
* Permission set now grants repo:write on both
com.atmosphereaccount.registry.profile AND
com.atmosphereaccount.registry.featured. Without the second, the
curator's existing OAuth token couldn't putRecord featured/self
from the new /admin/featured editor (PDS replied with
ScopeMissingError on com.atmosphereaccount.registry.featured?action=create).
Requires republishing the lexicon (deno task lex:publish:update) and
the curator re-authenticating so the issued token resolves the new
permission set.
* Apply explore-no-effects on /admin/* the same way we already do for
/explore/* and /developer-resources — the sky toggle and animation
are noise on the operator surface.
Made-with: Cursor
CREATE INDEX IF NOT EXISTS profile_takedown ON profile(takedown_status)
ran inside SCHEMA_STATEMENTS, before applyAdditiveMigrations had a
chance to ALTER TABLE in the new takedown_status column on existing
databases. The index creation aborted with "no such column:
takedown_status", which short-circuited migrate() entirely and broke
every withDb() caller (visible as a SQL_INPUT_ERROR during login).
Move column-dependent indexes into POST_MIGRATION_INDEX_STATEMENTS,
applied after applyAdditiveMigrations so pre-existing tables gain the
column first.
Made-with: Cursor