commits
- 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
Adds an in-app admin dashboard at /admin gated by ADMIN_DIDS allowlist
(non-admins get 404 to avoid leaking the section's existence).
* DB: new icon_status / icon_reviewed_* / icon_rejected_reason columns
on profile, new takedown_status / takedown_reason / takedown_by /
takedown_at columns + profile_takedown index, new report table.
upsertProfile preserves icon + takedown state across firehose updates.
* Public reads (search, featured, single-profile API, icon API,
/explore/[handle], picker) now filter taken-down profiles by default.
PUT /api/registry/profile refuses updates on taken-down accounts (403).
* Admin pages: /admin overview with counts, /admin/icons, /admin/reports,
/admin/featured, /admin/takedowns; matching islands for each row/editor.
* Admin APIs: icon approve/reject/preview, report resolve, featured
publish (replaces com.atmosphereaccount.registry.featured/self),
profile takedown/restore (takedown auto-resolves open reports).
* Public: report-profile button + modal on /explore/[handle], soft
per-IP rate limit + IP hashing via REPORT_IP_SECRET. Owner sees a
takedown banner on /explore/manage with the admin-supplied reason.
* lib/admin.ts (isAdmin / requireAdmin / requireAdminApi),
lib/reports.ts, env additions (ADMIN_DIDS, REPORT_IP_SECRET) +
.env.example documentation. i18n strings + CSS for all UI surfaces.
Made-with: Cursor
Per the atproto Lexicon spec, NSID resolution is DNS-based (TXT record at
_lexicon.<reversed-authority> pointing at a DID, plus a schema record on
that DID's PDS) — not HTTP. Without that infrastructure, PDSes returned
`invalid_scope` for include:com.atmosphereaccount.registry.fullPermissions
and login was blocked.
- Drop the blob permission from fullPermissions.json: the permission spec
forbids blob permissions inside permission sets, so goat lex publish
would have rejected the schema. Add blob:image/* as a top-level scope
alongside the include: in lib/oauth.ts and client-metadata.json.ts —
same effective access, valid spec shape.
- Add deno tasks (lex:lint, lex:check-dns, lex:status, lex:publish,
lex:publish:update) wrapping goat for one-shot DNS verification and
publication.
- Document the full one-time setup (DNS TXT at Porkbun + app password +
goat lex publish) and day-to-day workflow in docs/PUBLISHING_LEXICONS.md.
Made-with: Cursor
Adds an optional vector icon field on registry profiles, exposed only
via the public read API for developers building badges, app showcases,
and programmatic listings. Not rendered on the public Explore profile.
Backend
- lexicon: new optional `icon` blob (image/svg+xml, 200KB max)
- DB: additive icon_cid/icon_mime columns; existing deployments
pick them up via ALTER TABLE — no wipe required
- validator + registry queries + jetstream indexer all extended in
lockstep with the existing avatar plumbing
Sanitisation + serve path
- lib/svg-sanitize.ts strips <script>, <style>, <foreignObject>,
on*= handlers, comments/PIs/DOCTYPEs, and href/xlink:href values
pointing at javascript: or non-image data: URLs
- new GET /api/registry/icon/:did proxies the bytes with
Content-Type: image/svg+xml, X-Content-Type-Options: nosniff, and
a strict Content-Security-Policy so any script that survived the
sanitiser is neutralised at render time
- GET /api/registry/profile/:id now includes an `iconUrl` next to
`avatarUrl`
UI
- "Developer icon (SVG, optional)" section on the manage form with
a 64px preview slot, mime + size validation client-side, and the
same keep/upload/remove pattern as the main avatar
- explainer text makes it clear the icon is dev-only and not shown
on the public profile
Docs
- DeveloperResources endpoint reference lists the new
/api/registry/icon/:did endpoint
- i18n strings under forms.profile.icon and the API docs
OAuth scope is unchanged — image/svg+xml is already covered by the
existing blob:image/* permission set.
Made-with: Cursor
Replaces the broad `transition:generic` scope with an `include:` reference
to a new `com.atmosphereaccount.registry.fullPermissions` permission-set
lexicon, which grants only the writes this app actually needs:
- repo writes to com.atmosphereaccount.registry.profile
- blob:image/* uploads (avatars)
The permission-set's title/detail render in the user's consent dialog,
so reducing scope also makes the prompt clearer.
Made-with: Cursor
Extend APP_SUBCATEGORIES with research, science, reviews, gaming,
community, food, location, liveStreaming, niche, content, and art.
Update i18n labels and profile lexicon description.
Made-with: Cursor
Replace inline query-string hints with a Parameters list under each
endpoint card. Add section-heading spacing for Endpoints and Schema.
Constrain playground snippet grid with minmax(0,1fr) and min-width:0
so long URLs scroll inside the card instead of breaking the layout.
Made-with: Cursor
Adds a self-service developer tool on /developer-resources for pulling
registry data into other apps. Includes:
- New public endpoint GET /api/registry/profile/:handleOrDid that
returns the same ProfileRow shape as the SSR explore page, plus a
synthesised avatarUrl convenience field.
- lib/rate-limit.ts: in-memory token bucket (~60 req/min/IP) wrapping
the four public read handlers (profile/:id, search, featured,
avatar/:did) as a soft deterrent against abuse.
- Interactive playground island with tabs for the three read endpoints,
inline JSON response, and copyable cURL + JS fetch snippets.
- Endpoint reference (server-rendered <dl>) and a download link for the
canonical profile lexicon, served via the existing wellknown
middleware at /lexicons/com.atmosphereaccount.registry.profile.json.
- Translatable copy under developerResources.api.* and matching CSS.
Made-with: Cursor
The description was redundant next to the Bluesky brand name. Kept
the secondary line for the multi-client case ('Bluesky + N more')
since that's actually informative.
Made-with: Cursor
Add a --lg modifier to .profile-form-button-secondary that bumps the
padding/font to match the primary + danger buttons in the form action
row, without changing the compact look of the existing secondary
callsites (modals, custom-link add, avatar picker label).
Made-with: Cursor
- The View public profile link now sits inside the form action row,
between Update profile and Remove from Explore — reads as the
natural read-only complement to the destructive actions. Pulled
the standalone link (and its one-off CSS) out of manage.tsx and
passed publicProfileHandle through to the island.
- /developer-resources now joins /explore in the "effects off" set:
sky-static is force-applied server-side and the Effects toggle in
the nav strip is hidden. Page is reference material — the parallax
competed with reading.
Made-with: Cursor
Public profile button changes:
- Add inline-SVG icon components for Bluesky (butterfly), Tangled
(dolly mark), and Website (globe). Each uses currentColor so it
picks up the site's primary blue (#254a9e) instead of the brand
colour baked into a favicon. Alt Bluesky clients (Blacksky,
Witchsky, etc.) keep their favicons so they remain distinguishable.
- ResolvedLink gets an optional iconKind tag; ProfileLinks switches
on it to render the right inline SVG, falling back to the favicon
iconUrl, then a glyph.
- Drop the URL subtitle from atmosphere + website buttons. The title
alone is the affordance; the URL is just the destination.
Footer:
- 'Hosted on:' → 'Account Provider:', sourced from a new
lib/account-providers.ts mapper. Per-shard PDS hosts like
shimeji.us-east.host.bsky.network now collapse to "Bluesky".
Self-hosted PDSes still show the bare hostname.
Made-with: Cursor
Made-with: Cursor
The form's avatar-preview initialiser checked initial.avatar first
and pointed the <img> at /api/registry/avatar/<did>. On the
Bluesky-prefill path initial.avatar is set (so the BlobRef carries
through on Save) but the registry record doesn't exist yet, so the
proxy 404'd, onError fired, and the slot collapsed to the empty +
placeholder. Reorder the precedence to prefer an explicit
initialAvatarUrl when supplied.
feat(form): URL-override popup for Tangled / Supper
Replace the inline URL-override input on simple atmosphere rows with
a gear button that opens a centered modal (mirrors the Bluesky
picker pattern). The modal explains what the default URL would be,
lets users type an override, and offers a Reset button to clear it
and fall back to the handle-derived default. The row's subtitle
flips to "Using custom URL" when an override is active.
Made-with: Cursor
Made-with: Cursor
The /api/me/avatar proxy occasionally fails to render in the form
slot on first sign-in (broken-image glyph), even though the upstream
PDS getBlob succeeds. Cut out the proxy hop entirely for the prefill
case: build the deterministic public Bluesky CDN URL from the
did + cid we already have. Also add an onError fallback on the form's
avatar <img> so any broken source collapses to the empty placeholder
instead of leaving the browser glyph.
Made-with: Cursor
Replace per-kind links[] schema with a small atmosphere-link model
(bsky / tangled / supper, plus website + custom "other") and remove
the standalone license record entirely.
- lexicon: simplify linkEntry to {kind, url?, clientId?, label?}; drop
bskyClient root field; delete license.json
- bsky URLs are derived from the user's handle + selected clientId at
render time; tangled / supper accept an optional URL override
- form: atmosphere toggles with a stacked-icon Bluesky row + gear that
opens a centered modal popup for multi-selecting bsky clients;
dedicated website field + custom-link editor with delete buttons
- supper toggle hidden until supper.support is live (lexicon kept)
- ProfileLinks renders via lib/atmosphere-links#resolveLink so the
detail page no longer branches per kind
- drop license / openSource badge from cards/hero, drop license + bsky
client copy from i18n, drop license + link-editor styles, add
atmosphere-toggle / modal / custom-link styles
- worker: drop LICENSE_NSID + handleLicenseEvent; index profiles
without bskyClient
- db: drop license table + bsky_client column from profile schema
Made-with: Cursor
Embedding the raw PDS getBlob URL in an <img> tag was producing a
broken image preview in the manage form for users without a registry
record yet. Some PDS hosts don't serve blobs cleanly to a browser
(content-type, redirects, CORS quirks), but they all work fine for a
server-to-server fetch.
We already have /api/me/avatar that proxies the bsky avatar through
this server with a friendly content-type and cache headers, including
the prefill fallback for users with no registry profile yet. Point
initialAvatarUrl at it instead of the direct PDS URL.
Made-with: Cursor
Profile lexicon now stores outbound links as a `links[]` array of
`{kind, url, label?}` entries so the surface stays minimal and any new
link kind (donate, mastodon, matrix, discord, contact, …) is additive
without a new field. The previous `website`, `repoUrl`, and `openSource`
fields are removed.
License moves to its own record (`com.atmosphereaccount.registry.license`,
key=self) so the profile lexicon stays small and we can grow license
metadata independently. The form still presents both as a single Save
action; the API writes/deletes both records and the index joins them by
DID so badges keep working.
`developerTool` is now a recognised category. Categories use lexicon
`knownValues`, so adding more later remains a non-breaking change.
Other changes:
- New lib/link-kinds.ts: kind→icon/label resolver. `repo` defers to
detectRepoHost() so GitHub/Tangled branding still surfaces.
- pds.ts gains a generic deleteRecord() for the license sibling record.
- registry.ts: profile rows now carry a `links: LinkEntry[]` and
optional joined `license`. New upsertLicense / deleteLicense /
getLicenseByDid helpers; deleteProfile cascades to license.
- worker/indexer.ts subscribes to the license collection too.
- Form: dynamic links editor (add/remove/inline kind picker) and a
collapsed license section with type/SPDX/url/notes.
- Wipe script drops the new license table.
The Turso index has been wiped to drop legacy columns; lib/db.ts will
recreate the schema on first request.
Made-with: Cursor
Reshape the registry profile around a required `categories[]` array (1-4)
so a project can declare both "App" and "Account provider", and drop the
legacy single `category` and `tags` fields entirely. Categories now read
as singular labels everywhere ("App", not "Apps").
Profile additions:
- `repoUrl` (auto-detects GitHub / Tangled / generic) renders as a third
action button next to Bluesky and Website.
- `openSource` toggle adds an "Open source" badge on cards and the hero.
Sign-in UX:
- New "Sign in with your Atmosphere handle" label above the input.
- Trim placeholder to `yourproject.com`, drop the "we resolve your
handle" helper line.
Top-nav:
- Drop the Protocol button from the top right (still in footer).
- Promote Explore to the glass-button slot.
- Account menu rail under Explore: text "Sign in" when signed out,
avatar pill with View/Manage/Sign-out dropdown when signed in.
- New `/api/me/avatar` route serves the registry avatar (cached) with
a Bluesky PDS fallback for users who haven't published yet.
Home + footer:
- New homepage CTA section ("Explore Apps" glass button after the
moderation/algorithms section).
- Footer gains a `compact` variant: hide tagline + quote + the Explore
link on the explore section.
- Centre footer links under the logo.
Schema migration:
- DB schema drops `category`, `tags`, `tags`-in-FTS; adds `categories`,
`repo_url`, `open_source`.
- New `scripts/wipe-registry.ts` recreates the tables cleanly when the
schema can't be expressed via additive ALTERs.
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
Adds an in-app admin dashboard at /admin gated by ADMIN_DIDS allowlist
(non-admins get 404 to avoid leaking the section's existence).
* DB: new icon_status / icon_reviewed_* / icon_rejected_reason columns
on profile, new takedown_status / takedown_reason / takedown_by /
takedown_at columns + profile_takedown index, new report table.
upsertProfile preserves icon + takedown state across firehose updates.
* Public reads (search, featured, single-profile API, icon API,
/explore/[handle], picker) now filter taken-down profiles by default.
PUT /api/registry/profile refuses updates on taken-down accounts (403).
* Admin pages: /admin overview with counts, /admin/icons, /admin/reports,
/admin/featured, /admin/takedowns; matching islands for each row/editor.
* Admin APIs: icon approve/reject/preview, report resolve, featured
publish (replaces com.atmosphereaccount.registry.featured/self),
profile takedown/restore (takedown auto-resolves open reports).
* Public: report-profile button + modal on /explore/[handle], soft
per-IP rate limit + IP hashing via REPORT_IP_SECRET. Owner sees a
takedown banner on /explore/manage with the admin-supplied reason.
* lib/admin.ts (isAdmin / requireAdmin / requireAdminApi),
lib/reports.ts, env additions (ADMIN_DIDS, REPORT_IP_SECRET) +
.env.example documentation. i18n strings + CSS for all UI surfaces.
Made-with: Cursor
Per the atproto Lexicon spec, NSID resolution is DNS-based (TXT record at
_lexicon.<reversed-authority> pointing at a DID, plus a schema record on
that DID's PDS) — not HTTP. Without that infrastructure, PDSes returned
`invalid_scope` for include:com.atmosphereaccount.registry.fullPermissions
and login was blocked.
- Drop the blob permission from fullPermissions.json: the permission spec
forbids blob permissions inside permission sets, so goat lex publish
would have rejected the schema. Add blob:image/* as a top-level scope
alongside the include: in lib/oauth.ts and client-metadata.json.ts —
same effective access, valid spec shape.
- Add deno tasks (lex:lint, lex:check-dns, lex:status, lex:publish,
lex:publish:update) wrapping goat for one-shot DNS verification and
publication.
- Document the full one-time setup (DNS TXT at Porkbun + app password +
goat lex publish) and day-to-day workflow in docs/PUBLISHING_LEXICONS.md.
Made-with: Cursor
Adds an optional vector icon field on registry profiles, exposed only
via the public read API for developers building badges, app showcases,
and programmatic listings. Not rendered on the public Explore profile.
Backend
- lexicon: new optional `icon` blob (image/svg+xml, 200KB max)
- DB: additive icon_cid/icon_mime columns; existing deployments
pick them up via ALTER TABLE — no wipe required
- validator + registry queries + jetstream indexer all extended in
lockstep with the existing avatar plumbing
Sanitisation + serve path
- lib/svg-sanitize.ts strips <script>, <style>, <foreignObject>,
on*= handlers, comments/PIs/DOCTYPEs, and href/xlink:href values
pointing at javascript: or non-image data: URLs
- new GET /api/registry/icon/:did proxies the bytes with
Content-Type: image/svg+xml, X-Content-Type-Options: nosniff, and
a strict Content-Security-Policy so any script that survived the
sanitiser is neutralised at render time
- GET /api/registry/profile/:id now includes an `iconUrl` next to
`avatarUrl`
UI
- "Developer icon (SVG, optional)" section on the manage form with
a 64px preview slot, mime + size validation client-side, and the
same keep/upload/remove pattern as the main avatar
- explainer text makes it clear the icon is dev-only and not shown
on the public profile
Docs
- DeveloperResources endpoint reference lists the new
/api/registry/icon/:did endpoint
- i18n strings under forms.profile.icon and the API docs
OAuth scope is unchanged — image/svg+xml is already covered by the
existing blob:image/* permission set.
Made-with: Cursor
Replaces the broad `transition:generic` scope with an `include:` reference
to a new `com.atmosphereaccount.registry.fullPermissions` permission-set
lexicon, which grants only the writes this app actually needs:
- repo writes to com.atmosphereaccount.registry.profile
- blob:image/* uploads (avatars)
The permission-set's title/detail render in the user's consent dialog,
so reducing scope also makes the prompt clearer.
Made-with: Cursor
Adds a self-service developer tool on /developer-resources for pulling
registry data into other apps. Includes:
- New public endpoint GET /api/registry/profile/:handleOrDid that
returns the same ProfileRow shape as the SSR explore page, plus a
synthesised avatarUrl convenience field.
- lib/rate-limit.ts: in-memory token bucket (~60 req/min/IP) wrapping
the four public read handlers (profile/:id, search, featured,
avatar/:did) as a soft deterrent against abuse.
- Interactive playground island with tabs for the three read endpoints,
inline JSON response, and copyable cURL + JS fetch snippets.
- Endpoint reference (server-rendered <dl>) and a download link for the
canonical profile lexicon, served via the existing wellknown
middleware at /lexicons/com.atmosphereaccount.registry.profile.json.
- Translatable copy under developerResources.api.* and matching CSS.
Made-with: Cursor
- The View public profile link now sits inside the form action row,
between Update profile and Remove from Explore — reads as the
natural read-only complement to the destructive actions. Pulled
the standalone link (and its one-off CSS) out of manage.tsx and
passed publicProfileHandle through to the island.
- /developer-resources now joins /explore in the "effects off" set:
sky-static is force-applied server-side and the Effects toggle in
the nav strip is hidden. Page is reference material — the parallax
competed with reading.
Made-with: Cursor
Public profile button changes:
- Add inline-SVG icon components for Bluesky (butterfly), Tangled
(dolly mark), and Website (globe). Each uses currentColor so it
picks up the site's primary blue (#254a9e) instead of the brand
colour baked into a favicon. Alt Bluesky clients (Blacksky,
Witchsky, etc.) keep their favicons so they remain distinguishable.
- ResolvedLink gets an optional iconKind tag; ProfileLinks switches
on it to render the right inline SVG, falling back to the favicon
iconUrl, then a glyph.
- Drop the URL subtitle from atmosphere + website buttons. The title
alone is the affordance; the URL is just the destination.
Footer:
- 'Hosted on:' → 'Account Provider:', sourced from a new
lib/account-providers.ts mapper. Per-shard PDS hosts like
shimeji.us-east.host.bsky.network now collapse to "Bluesky".
Self-hosted PDSes still show the bare hostname.
Made-with: Cursor
The form's avatar-preview initialiser checked initial.avatar first
and pointed the <img> at /api/registry/avatar/<did>. On the
Bluesky-prefill path initial.avatar is set (so the BlobRef carries
through on Save) but the registry record doesn't exist yet, so the
proxy 404'd, onError fired, and the slot collapsed to the empty +
placeholder. Reorder the precedence to prefer an explicit
initialAvatarUrl when supplied.
feat(form): URL-override popup for Tangled / Supper
Replace the inline URL-override input on simple atmosphere rows with
a gear button that opens a centered modal (mirrors the Bluesky
picker pattern). The modal explains what the default URL would be,
lets users type an override, and offers a Reset button to clear it
and fall back to the handle-derived default. The row's subtitle
flips to "Using custom URL" when an override is active.
Made-with: Cursor
The /api/me/avatar proxy occasionally fails to render in the form
slot on first sign-in (broken-image glyph), even though the upstream
PDS getBlob succeeds. Cut out the proxy hop entirely for the prefill
case: build the deterministic public Bluesky CDN URL from the
did + cid we already have. Also add an onError fallback on the form's
avatar <img> so any broken source collapses to the empty placeholder
instead of leaving the browser glyph.
Made-with: Cursor
Replace per-kind links[] schema with a small atmosphere-link model
(bsky / tangled / supper, plus website + custom "other") and remove
the standalone license record entirely.
- lexicon: simplify linkEntry to {kind, url?, clientId?, label?}; drop
bskyClient root field; delete license.json
- bsky URLs are derived from the user's handle + selected clientId at
render time; tangled / supper accept an optional URL override
- form: atmosphere toggles with a stacked-icon Bluesky row + gear that
opens a centered modal popup for multi-selecting bsky clients;
dedicated website field + custom-link editor with delete buttons
- supper toggle hidden until supper.support is live (lexicon kept)
- ProfileLinks renders via lib/atmosphere-links#resolveLink so the
detail page no longer branches per kind
- drop license / openSource badge from cards/hero, drop license + bsky
client copy from i18n, drop license + link-editor styles, add
atmosphere-toggle / modal / custom-link styles
- worker: drop LICENSE_NSID + handleLicenseEvent; index profiles
without bskyClient
- db: drop license table + bsky_client column from profile schema
Made-with: Cursor
Embedding the raw PDS getBlob URL in an <img> tag was producing a
broken image preview in the manage form for users without a registry
record yet. Some PDS hosts don't serve blobs cleanly to a browser
(content-type, redirects, CORS quirks), but they all work fine for a
server-to-server fetch.
We already have /api/me/avatar that proxies the bsky avatar through
this server with a friendly content-type and cache headers, including
the prefill fallback for users with no registry profile yet. Point
initialAvatarUrl at it instead of the direct PDS URL.
Made-with: Cursor
Profile lexicon now stores outbound links as a `links[]` array of
`{kind, url, label?}` entries so the surface stays minimal and any new
link kind (donate, mastodon, matrix, discord, contact, …) is additive
without a new field. The previous `website`, `repoUrl`, and `openSource`
fields are removed.
License moves to its own record (`com.atmosphereaccount.registry.license`,
key=self) so the profile lexicon stays small and we can grow license
metadata independently. The form still presents both as a single Save
action; the API writes/deletes both records and the index joins them by
DID so badges keep working.
`developerTool` is now a recognised category. Categories use lexicon
`knownValues`, so adding more later remains a non-breaking change.
Other changes:
- New lib/link-kinds.ts: kind→icon/label resolver. `repo` defers to
detectRepoHost() so GitHub/Tangled branding still surfaces.
- pds.ts gains a generic deleteRecord() for the license sibling record.
- registry.ts: profile rows now carry a `links: LinkEntry[]` and
optional joined `license`. New upsertLicense / deleteLicense /
getLicenseByDid helpers; deleteProfile cascades to license.
- worker/indexer.ts subscribes to the license collection too.
- Form: dynamic links editor (add/remove/inline kind picker) and a
collapsed license section with type/SPDX/url/notes.
- Wipe script drops the new license table.
The Turso index has been wiped to drop legacy columns; lib/db.ts will
recreate the schema on first request.
Made-with: Cursor
Reshape the registry profile around a required `categories[]` array (1-4)
so a project can declare both "App" and "Account provider", and drop the
legacy single `category` and `tags` fields entirely. Categories now read
as singular labels everywhere ("App", not "Apps").
Profile additions:
- `repoUrl` (auto-detects GitHub / Tangled / generic) renders as a third
action button next to Bluesky and Website.
- `openSource` toggle adds an "Open source" badge on cards and the hero.
Sign-in UX:
- New "Sign in with your Atmosphere handle" label above the input.
- Trim placeholder to `yourproject.com`, drop the "we resolve your
handle" helper line.
Top-nav:
- Drop the Protocol button from the top right (still in footer).
- Promote Explore to the glass-button slot.
- Account menu rail under Explore: text "Sign in" when signed out,
avatar pill with View/Manage/Sign-out dropdown when signed in.
- New `/api/me/avatar` route serves the registry avatar (cached) with
a Bluesky PDS fallback for users who haven't published yet.
Home + footer:
- New homepage CTA section ("Explore Apps" glass button after the
moderation/algorithms section).
- Footer gains a `compact` variant: hide tagline + quote + the Explore
link on the explore section.
- Centre footer links under the logo.
Schema migration:
- DB schema drops `category`, `tags`, `tags`-in-FTS; adds `categories`,
`repo_url`, `open_source`.
- New `scripts/wipe-registry.ts` recreates the tables cleanly when the
schema can't be expressed via additive ALTERs.
Made-with: Cursor