0010 — Publication system: handle-namespaced URLs, frozen slugs, ownership, delete#
- Status: Accepted
- Date: 2026-06-08
- Scope: SP10 — publication dashboard, creation flow, publication pages
Context#
SkyPress auto-created one invisible publication per user (name = handle, url = /@handle)
and matched it by URL. We now support multiple, user-managed publications with a
dashboard, logos, and publication-centric public pages. This forces decisions on the URL
model, slugs, which publications SkyPress "owns", and what deleting one does — all under the
brief's hard no-database / no-KV constraint.
Decision#
1. Handle-namespaced URLs (option A), no global slug registry#
/@handle author index
/@handle/{slug} publication home
/@handle/{slug}/{rkey} document
A publication's url becomes https://skypress.blog/@{handle}/{slug}. Everything resolves
from the PDS: resolve handle → DID → PDS, list that one repo's publications, match by slug
segment. No global registry means subdomains (slug.skypress.blog) are explicitly out
for v1 (they'd need a KV/D1 registry + wildcard DNS — a separate sub-project).
2. Slugs are auto-derived and frozen#
The slug is derived from the name at creation (slugify), de-duplicated within the user's
own repo only (-2, -3, …), and frozen into publication.url. Later name edits never
change it — URLs must never break (consistent with documents addressed by stable rkey,
Decisions 0005/0008). Empty/emoji-only names fall back to pub-{rkey} (the publication
record's own TID rkey is globally unique → no collision check). The rkey is generated up
front (TID.nextStr(), the publisher.ts pattern) so the fallback URL is known before the
record is written.
3. SkyPress only manages publications it owns (origin === SITE_BASE)#
A user may hold site.standard.publication records from other tools sharing the lexicon
(Leaflet, etc.). SkyPress lists, edits, deletes, renders, and resolves only publications
whose url origin equals SITE_BASE (isSkyPressPublicationUrl). This keeps the dashboard
from offering to delete a foreign tool's publication, keeps slug resolution unambiguous, and
honours the lexicon's "additions only / don't touch others' data" discipline. The replaced
ensurePublication matched a single pub by exact URL for the same reason; this generalises it.
4. Publishing targets a chosen publication#
ensurePublication (auto-create one pub per handle) is removed. publish() now takes the
target publication's { uri, slug }; the editor presents a selector. A user with no
publication is sent to the dashboard to create one first. Resolution still verifies
doc.site === publication.uri so a document only renders under its true publication.
5. Deleting a publication cascades#
Danger-zone delete removes the publication and its documents and each document's
companion app.bsky.feed.post (reusing unpublish semantics, Decision 0008), then the
publication record. The alternative — refusing to delete a non-empty publication — leaves
orphaned, unreachable documents in the PDS and is worse UX. The confirm dialog states exactly
how many articles and Bluesky posts will be removed, since this is destructive and outward-
facing. Blobs (logo, images) become unreferenced and the PDS garbage-collects them.
Consequences#
publicationHomeUrl(handle, slug)andcanonicalArticleUrl(handle, slug, rkey)change signatures;records.test.tsis updated (TDD).- The reader route splits:
[author]/[slug]/index.astro+[author]/[slug]/[rkey].astro;[author]/[rkey].astrois removed. No migration — skypress.blog is not yet live. /@handlebecomes the author index, reviving the paused profile work: it readsapp.bsky.actor.profilefrom the PDS directly (no Bluesky appview dependency) for name/bio/avatar/banner; those are rendered as text/images, never injected as HTML.- Slug freezing means a renamed publication keeps a slug derived from its old name. Accepted: URL stability beats prettiness, and there is no slug registry to rename against.
Amendment (2026-06-09): foreign publications are displayed read-only#
Section 3 originally hid every non-owned site.standard.publication record from the
dashboard. Writers found this confusing: Leaflet's home shows all their publications, so
SkyPress appeared to have "lost" the ones created elsewhere.
SkyPress now displays foreign publications (origin ≠ SITE_BASE) on the dashboard in a
separate read-only "From other apps" section — name, logo, a hostname badge, and an outbound
link to the publication's own url. They are modelled with a distinct ForeignPublication
type so they are structurally incapable of reaching the manage/edit/delete paths.
The ownership guards are unchanged: SkyPress still manages, renders, and resolves only
publications it owns (isSkyPressPublicationUrl). Only listing/visibility expanded.