Skip to content

Add meta tags for AT URIs#365

Open
ChrisShank wants to merge 9 commits into
hyperlink-academy:mainfrom
ChrisShank:meta-tags
Open

Add meta tags for AT URIs#365
ChrisShank wants to merge 9 commits into
hyperlink-academy:mainfrom
ChrisShank:meta-tags

Conversation

@ChrisShank

Copy link
Copy Markdown

This PR implements the community proposal for mapping web pages back AT URIs. It removes link[rel=alternate] tags, but keeps the link[rel=standard.site.document] tags. If there are worries that this is a breaking change, we can keep <link> for the time being.

As for standard.site using the new proposal I'm thinking that it would look like this. I think the first step is to get the big 3 to start publishing these tags.

<meta name="at:canonical" content="at://did:plc:xyz789/site.standard.document/rkey" />
<meta name="at:alternate" content="at://did:plc:abc123/site.standard.publication/rkey" />

jaredpereira and others added 8 commits July 21, 2026 18:12
* WIP: paid memberships — tiers, members-only content, and checkout

Adds the paid-membership flow on top of the membership tables:

- membersOnlyDelimiter block (lexicon + editor + publish serialization)
  marking where members-only content begins
- server-side gating of post content past the delimiter for
  non-members (post page, feeds, reader enrichment, tag pages,
  email broadcasts)
- MembersOnlyPaywall and /join tier page with Stripe checkout
  (startMembershipCheckout), plus membership settings in the
  publication dashboard
- Stripe webhook handling for membership subscriptions
  (checkout completed, updated, deleted, payment failed)
- subscribe flows redirect new subscribers to the join page when
  a publication has memberships enabled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Memberships: direct charges on connected accounts + platform wallet

Move paid-membership billing from destination charges to direct charges on
each publisher's connected Stripe account, with a platform wallet that clones
a reader's saved card onto every publisher account.

- DB: stripe_wallets, stripe_connected_customers; publication_memberships gains
  stripe_account_id/cadence/stripe_price_id.
- Tiers provision Products/Prices on the publisher's connected account.
- Card collection via hosted Checkout (mode:setup) — no Stripe.js/publishable
  key. Saved card charged server-side off_session; auth-required/decline falls
  back to Stripe's hosted invoice page. Tier/cadence carried through the redirect
  so first-time joins auto-complete on return.
- New connect-events webhook (listens on connected accounts); membership branches
  removed from the platform v1 webhook handlers.
- Reader self-service at /memberships (cancel/resume/switch tier+cadence, update
  card); new_member notification; failed-payment email.
- Subscribers dashboard tags members (badge + filter). disableMemberships guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix critical and major billing/correctness issues from paid-memberships review (hyperlink-academy#346)

* Fix critical membership billing gaps: webhook reconciliation, identity merge, setup-mode sessions

- connect-events webhook can now rebuild a membership row from subscription
  metadata when no row matches the subscription id (failed join-flow write,
  row deleted by identity merge), re-fetching the live subscription first so
  stale event snapshots can't create or resurrect state. Read/write errors on
  this path now throw so Stripe redelivers.
- subscribeToTier sweeps and cancels older duplicate live subscriptions for
  the same reader+publication after the row upsert (concurrent joins with
  different tier/cadence bypass the idempotency key).
- Identity merge now migrates publication_memberships, stripe_wallets,
  stripe_connected_customers, and stripe_connected_accounts instead of letting
  the identities FK cascade delete them while Stripe keeps billing. On
  collision the active membership wins, target preferred on ties.
- Platform checkout.session.completed handler ignores non-subscription-mode
  sessions instead of throwing: wallet card collection uses setup-mode
  sessions on the platform account, and each one was a guaranteed 500 retry
  loop against the Pro billing endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138kgpJVUG36MnMw1tdpmY3

* Fix membership review findings: webhook ordering, tier sync, notifications

- Throw on remaining ignored Supabase errors in connect-events handlers
  (invoice-failed update, payment-failed email reads, notification insert)
  so the route 500s and Stripe redelivers instead of ACKing lost writes
- Re-retrieve the subscription from Stripe before updating a tracked
  membership row so out-of-order webhook events can't resurrect a canceled
  membership; invoice-succeeded passes fresh: true to avoid a double fetch
- Reorder tier price sync to create new prices and persist them to the DB
  before archiving replaced ones, and verify a same-amount price is still
  active before reusing it, so a mid-sync failure can't permanently point
  a tier at an archived price
- Insert new tier rows with active: false so half-provisioned tiers are
  never offered; also require a monthly price id on the join page
- Notify publishers of new members on the inline join path, deduped by
  membership id via a shared notifyNewMember in src/notifications.ts
  (the webhook path only fired on inactive-to-active transitions, which
  the inline flow preempts)
- Confirm with Stripe that a lapsed-looking subscription is really
  inactive before canceling it in subscribeToTier; if it's live, refresh
  the row from Stripe and return already_member instead
- Recompute documents_in_publications.members_only after inflating
  blob-offloaded pages in sync_document_metadata, closing the gap where
  gated content was served ungated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138kgpJVUG36MnMw1tdpmY3

* Move notifyNewMember out of the use-server notifications module

src/notifications.ts is "use server", so exporting notifyNewMember there
made it a wire-callable action anyone could invoke with arbitrary
publication/membership ids. It now lives in src/membership.server.ts, a
plain server module only importable by server code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138kgpJVUG36MnMw1tdpmY3

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Show and manage the viewer's membership in the manage subscription modal

The modal now shows the member's tier and price, with Change plan and
Cancel (with a confirm screen) swapping the modal's content in place.
The tier switcher, cancel confirm, and action buttons are extracted from
the memberships & billing page into shared components, and the
membership is fetched per publication via an SWR hook gated on the
identity's membership rows, with the whole modal behind a loader while
it loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep members-only content out of every public copy of a post

The PDS record (and the offloaded pages blob) is public regardless of
serve-time gating, so truncate gated posts at the members-only delimiter
in every path that writes a document record to a PDS: publish (both
record formats), the bskyPostRef re-put, restores, and the record-fixing
backfills. The full record now lives only in documents.data; the appview
skips re-indexing gated leaflet-managed posts so the truncated firehose
copy can't clobber it.

Also close the two unauthenticated RPCs that returned full records for
any URI (get_document_interactions, get_standard_site_posts), and split
the newsletter broadcast into full-body emails for active members (plus
owner and confirmed contributors) and preview emails for everyone else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* render membership thigns

* Keep the add-account modal open when launched from the mobile sidebar

On mobile the ProfileButton (and the login modal state it owns) is
mounted inside the sidebar's Dialog content, so closing the sidebar
while opening the add-account modal unmounted the modal immediately.
Leave the sidebar open underneath, like the other modals in this menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Serve members-only images from publish-owned storage instead of the PDS

Images past the members-only delimiter were uploaded as PDS blobs, but the
truncated public record doesn't reference them, leaving them unreferenced
(subject to PDS garbage collection) and publicly fetchable besides. Now
publish serialization threads a membersOnly flag through to the upload
hooks, and gated images skip the PDS entirely: they're copied into a
document-scoped folder (gated-documents/<did>/<rkey>/) so they survive
draft-asset deletion, deduplicated by the draft's uuid filename so
republishes reuse existing copies, and stale copies are garbage collected
after each successful publish. The record carries them as http(s) $link
blobrefs, which blobRefToSrc already passes through on every render path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove duplicated src/membership import

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* add admin entitlement ui

* Cancel member subscriptions before deleting a publication

deletePublication hard-deleted the publications row, cascading away the
membership rows while leaving members' Stripe subscriptions live on the
publisher's connected account — billing forever with no record and no
in-app way to cancel. Cancel every member subscription first, and abort
the delete if any can't be confirmed canceled.

Also guard reconcileUntrackedSubscription against a deleted publication:
renewal events for a residual orphan previously hit the publication FK
violation, 500ing the webhook and making Stripe retry indefinitely; now
they log for manual cancellation instead.

Also fix handleMembershipInvoiceFailed discarding the subscription
status it just re-fetched: it wrote a literal past_due, so a replayed or
out-of-order invoice.payment_failed arriving after the invoice recovered
would re-gate an active, paying member and send them a dunning email.
Write sub.status instead, and skip the payment-failed email when the
subscription is already active again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add min-w-0 to the list item content wrapper so flex items (e.g. bsky
embeds) can shrink below their intrinsic width instead of overflowing
the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a URL/HTML toggle to the embed block: pasted HTML is stored as an
embed/html fact and rendered via the iframe srcdoc attribute, sandboxed
without allow-same-origin so it can't script against the app. Published
via a new optional html field on pub.leaflet.blocks.iframe (url now
optional too).

The empty block is now resizable via the same height drag handle as
filled embeds, and the header offers aspect ratio presets (Fixed, 4:3,
1:1, 4:1) that persist through publish. Explicitly chosen sizes are no
longer overwritten by embed metadata on submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

@ChrisShank is attempting to deploy a commit to the Hyperlink Team on Vercel.

A member of the Team first needs to authorize it.

@jaredpereira

Copy link
Copy Markdown
Contributor

Amazing!! Yep I think a good idea to keep the link tags, unless they conflict for some reason?

@ChrisShank

ChrisShank commented Jul 24, 2026

Copy link
Copy Markdown
Author

@jaredpereira Added them back! There are no conflicts, but from some of the research I did AT URIs in standardized link tags could affect SEO, specifically for rel=canonical less so for rel=alternate.

@jaredpereira

Copy link
Copy Markdown
Contributor

There's a lot of other changes here, I think something got wierd with the git history. I accidentally commited some scratch files to the repo earlier and had to remove them from the history, so maybe rebasing your commits onto our main here could fix them? Sorry about the trouble, happy to do it as well if you run into anything more!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants