From 54755fa5978f632c8697c10791f6247eaa560e8d Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Wed, 5 Aug 2026 18:35:23 -0700 Subject: [PATCH] =?UTF-8?q?feat!:=20bare=20publisher=20paths=20=E2=80=94?= =?UTF-8?q?=20drop=20the=20@=20sigil=20from=20all=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /{publisher}/{project}@{selector}/{artifact} everywhere: handles are domains and DIDs carry did:, so the @ prefix was decoration. Applied across the front door, web routes and links, front-door URL builders, specs (artifact-hosting, release-indexing), design D15, the surface brief, and the deploy run-book's proxy note. Also: layout footer removed (the zero-bytes message lives in the record rail and home hero), and unresolvable identifiers 404 instead of 502. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PWXFcuaRLZycYVAF7senMK --- docs/deploy.md | 15 +++++---- internal/appview/frontdoor.go | 10 +++--- internal/appview/server.go | 4 +-- internal/appview/server_test.go | 12 +++---- .../dist-town-protocol-foundation/design.md | 2 +- .../specs/artifact-hosting/spec.md | 4 +-- .../specs/release-indexing/spec.md | 2 +- web/.gitignore | 32 +++++++++++++++++++ web/.impeccable/live/config.json | 6 ++++ web/.impeccable/surfaces/web.md | 2 +- web/src/lib/components/ProjectPage.svelte | 10 +++--- web/src/lib/components/ReleasePage.svelte | 2 +- web/src/lib/frontdoor.ts | 4 +-- web/src/lib/server/api.ts | 4 ++- web/src/routes/+layout.svelte | 21 +++--------- web/src/routes/+page.svelte | 4 +-- web/src/routes/[ident]/[slug]/+page.server.ts | 8 ++--- 17 files changed, 88 insertions(+), 54 deletions(-) create mode 100644 web/.gitignore create mode 100644 web/.impeccable/live/config.json diff --git a/docs/deploy.md b/docs/deploy.md index 84c0b62..0786e32 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -58,17 +58,20 @@ PORT=3000 node build/index.js Route by path, one hostname: -- `/xrpc/*`, `/@*` (front door), `/tap/webhook`, `/healthz` → :8480 +- `/xrpc/*`, publisher paths (front door), `/tap/webhook`, `/healthz` → :8480 - `/?go-get=1` and `/go` → serve `tools/vanity/index.html` (go-import meta; any path under dist.town should serve it when the `go-get=1` query is present) - everything else → :3000 (SvelteKit) -Note: the SvelteKit pages and the front door share the `/@...` URL -shape. The front door owns three-segment artifact paths -(`/@p/proj@sel/artifact`); the web pages own one- and two-segment -paths (`/@p/proj`, `/@p/proj@sel`). Simplest proxy rule: send -`/@*/*/*` (three segments) to :8480, shorter `/@` paths to :3000. +Note: the SvelteKit pages and the front door share the bare +publisher-path URL shape (no @ sigil; handles are domains, DIDs carry +did:). The front door owns three-segment artifact paths +(`/pub/proj@sel/artifact`); the web pages own one- and two-segment +paths (`/pub/proj`, `/pub/proj@sel`). Simplest proxy rule: send +three-segment publisher paths to :8480, shorter ones to :3000, and +reserved literal prefixes (`/xrpc/`, `/healthz`, `/tap/`) always +to :8480. ## 5. Publisher setup (dogfood) diff --git a/internal/appview/frontdoor.go b/internal/appview/frontdoor.go index 8ef44c3..ef2c837 100644 --- a/internal/appview/frontdoor.go +++ b/internal/appview/frontdoor.go @@ -13,7 +13,7 @@ import ( ) // The front door: stable URLs, 302-only, zero artifact bytes served -// (design D15). URL shape: /@{handle-or-did}/{project}@{selector}/{artifact} +// (design D15). URL shape: /{handle-or-did}/{project}@{selector}/{artifact} // // Yank behavior follows the who's-asking table (design D17): a // selector that is a literal version serves with the status attached; @@ -24,10 +24,12 @@ import ( const statusHeader = "X-Dist-Status" func (s *Server) frontDoorRelease(w http.ResponseWriter, r *http.Request) (did, pds string, rel *canon.Release, viaPointer bool, ok bool) { - ident, hasAt := strings.CutPrefix(r.PathValue("ident"), "@") + // Publishers route bare: a handle is a domain, a DID carries its + // own did: prefix. No sigil needed. + ident := r.PathValue("ident") project, selector, specOK := parseSpec(r.PathValue("spec")) - if !hasAt || !specOK { - http.Error(w, "expected /@publisher/project@selector/...", http.StatusNotFound) + if !specOK { + http.Error(w, "expected /publisher/project@selector/...", http.StatusNotFound) return "", "", nil, false, false } did, _, pds, p, err := s.resolveProject(r.Context(), ident, project) diff --git a/internal/appview/server.go b/internal/appview/server.go index dd5b441..e52a550 100644 --- a/internal/appview/server.go +++ b/internal/appview/server.go @@ -33,8 +33,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /xrpc/town.dist.resolveRelease", s.handleResolveRelease) mux.HandleFunc("GET /xrpc/town.dist.listReleases", s.handleListReleases) mux.HandleFunc("GET /xrpc/town.dist.getProject", s.handleGetProject) - // Wildcards must span whole segments, so the @ prefix is - // validated in the handler rather than the pattern. The literal + // Publisher paths route bare (no @ sigil). + // The literal // /xrpc/ patterns are more specific and take precedence. mux.HandleFunc("GET /{ident}/{spec}/{artifact}", s.handleFrontDoor) mux.HandleFunc("GET /{ident}/{spec}/", s.handleFrontDoorIndex) diff --git a/internal/appview/server_test.go b/internal/appview/server_test.go index ce9f441..8c7c483 100644 --- a/internal/appview/server_test.go +++ b/internal/appview/server_test.go @@ -214,7 +214,7 @@ func TestResolveReleaseSelectorDuality(t *testing.T) { func TestFrontDoorVerifiedRedirect(t *testing.T) { srv, store, _ := seed(t) h := srv.Handler() - rec := get(t, h, "/@"+handle+"/my-cli@latest/a.tar.gz") + rec := get(t, h, "/"+handle+"/my-cli@latest/a.tar.gz") if rec.Code != http.StatusFound { t.Fatalf("status %d: %s", rec.Code, rec.Body) } @@ -243,7 +243,7 @@ func TestFrontDoorPendingRefuses(t *testing.T) { "storage": map[string]any{"$type": "town.dist.defs#urlStorage", "url": "https://origin.invalid/c.tar.gz"}, }}, }) - rec := get(t, srv.Handler(), "/@"+handle+"/my-cli@3.0.0/c.tar.gz") + rec := get(t, srv.Handler(), "/"+handle+"/my-cli@3.0.0/c.tar.gz") if rec.Code != http.StatusTooEarly { t.Fatalf("pending location: status %d, want 425", rec.Code) } @@ -259,12 +259,12 @@ func TestFrontDoorYankSemantics(t *testing.T) { h := srv.Handler() // Channel resolution never answers with a yanked release. - rec := get(t, h, "/@"+handle+"/my-cli@latest/a.tar.gz") + rec := get(t, h, "/"+handle+"/my-cli@latest/a.tar.gz") if rec.Code != http.StatusConflict { t.Fatalf("pointer to yanked: status %d, want 409", rec.Code) } // Explicit version serves, with the status attached. - rec = get(t, h, "/@"+handle+"/my-cli@1.0.0/a.tar.gz") + rec = get(t, h, "/"+handle+"/my-cli@1.0.0/a.tar.gz") if rec.Code != http.StatusFound { t.Fatalf("explicit yanked version: status %d, want 302: %s", rec.Code, rec.Body) } @@ -277,13 +277,13 @@ func TestChecksumEndpoints(t *testing.T) { srv, _, _ := seed(t) h := srv.Handler() - rec := get(t, h, "/@"+handle+"/my-cli@1.0.0/SHA256SUMS") + rec := get(t, h, "/"+handle+"/my-cli@1.0.0/SHA256SUMS") wantLine := strings.TrimPrefix(helloDigest, "sha256:") + " a.tar.gz\n" if rec.Code != http.StatusOK || rec.Body.String() != wantLine { t.Fatalf("SHA256SUMS = %q (%d), want %q", rec.Body.String(), rec.Code, wantLine) } - rec = get(t, h, "/@"+handle+"/my-cli@latest/a.tar.gz.sha256") + rec = get(t, h, "/"+handle+"/my-cli@latest/a.tar.gz.sha256") if rec.Code != http.StatusOK || rec.Body.String() != wantLine { t.Fatalf(".sha256 = %q (%d)", rec.Body.String(), rec.Code) } diff --git a/openspec/changes/dist-town-protocol-foundation/design.md b/openspec/changes/dist-town-protocol-foundation/design.md index 9cf1f18..3fab758 100644 --- a/openspec/changes/dist-town-protocol-foundation/design.md +++ b/openspec/changes/dist-town-protocol-foundation/design.md @@ -128,7 +128,7 @@ Key property of the indirection: **immutable releases + mutable host declaration ### D15. Download front door: 302-only, with a layered verification stack — dist.town serves zero artifact bytes -One stable URL shape for everything: `dist.town/@handle/project@version/artifact` (with `@latest` etc. resolving through pointer records). The front door always redirects — dist.town never serves artifact bytes from its own disk. A front-door cache was designed and **rejected**: however "disposable," served bytes attach hosting-grade legal exposure (purge obligations; CSAM carries proactive scanning/reporting duties with no safe-harbor softness — unacceptable during dogfood), the full ingress/storage/egress cost triangle (egress scales with success), and a crack in rung 1's crisp promise that dist.town holds zero artifact bytes. The cache concept survives demoted and renamed: **opt-in, publisher-consented mirroring at rung 4**, bundled with platform storage where the legal/quota/billing apparatus exists anyway (see D16). +One stable URL shape for everything: `dist.town/{publisher}/{project}@{selector}/{artifact}` (with `@latest` etc. resolving through pointer records). The front door always redirects — dist.town never serves artifact bytes from its own disk. A front-door cache was designed and **rejected**: however "disposable," served bytes attach hosting-grade legal exposure (purge obligations; CSAM carries proactive scanning/reporting duties with no safe-harbor softness — unacceptable during dogfood), the full ingress/storage/egress cost triangle (egress scales with success), and a crack in rung 1's crisp promise that dist.town holds zero artifact bytes. The cache concept survives demoted and renamed: **opt-in, publisher-consented mirroring at rung 4**, bundled with platform storage where the legal/quota/billing apparatus exists anyway (see D16). The verification stack replacing it, layered cheapest-first: diff --git a/openspec/changes/dist-town-protocol-foundation/specs/artifact-hosting/spec.md b/openspec/changes/dist-town-protocol-foundation/specs/artifact-hosting/spec.md index 079afda..d11d431 100644 --- a/openspec/changes/dist-town-protocol-foundation/specs/artifact-hosting/spec.md +++ b/openspec/changes/dist-town-protocol-foundation/specs/artifact-hosting/spec.md @@ -8,11 +8,11 @@ BYOS storage, the download front door, verification, and the resolution waterfal The front door SHALL only redirect (302) to artifact locations; dist.town SHALL NOT serve artifact bytes from its own storage. Artifact bytes live on publisher-controlled storage. (Platform storage and publisher-consented mirroring arrive in a later change, behind the public host interface.) #### Scenario: Download is a redirect -- **WHEN** a client requests `dist.town/@handle/my-cli@1.2.3/my-cli-linux-amd64.tar.gz` +- **WHEN** a client requests `dist.town/{publisher}/my-cli@1.2.3/my-cli-linux-amd64.tar.gz` - **THEN** the response is a 302 to the artifact's resolved location, and no artifact bytes transit dist.town ### Requirement: Stable front-door URLs -The front door SHALL serve `dist.town/@handle/{project}@{selector}/{artifact-name}` where selector is a pointer name or literal version, resolving through the same rules as `resolveRelease`. +The front door SHALL serve `dist.town/{publisher}/{project}@{selector}/{artifact-name}` where selector is a pointer name or literal version, resolving through the same rules as `resolveRelease`. #### Scenario: latest in a curl one-liner - **WHEN** a script requests `…/my-cli@latest/my-cli-linux-amd64.tar.gz` diff --git a/openspec/changes/dist-town-protocol-foundation/specs/release-indexing/spec.md b/openspec/changes/dist-town-protocol-foundation/specs/release-indexing/spec.md index 61f02ea..0eeff66 100644 --- a/openspec/changes/dist-town-protocol-foundation/specs/release-indexing/spec.md +++ b/openspec/changes/dist-town-protocol-foundation/specs/release-indexing/spec.md @@ -51,4 +51,4 @@ Resolution of a project, pointer, and release — through to a digest-verified d #### Scenario: dist.town is unreachable - **WHEN** all dist.town services are down -- **THEN** a client resolves `@handle/my-cli` at `latest` to verified artifact bytes via the publisher's PDS and storage alone +- **THEN** a client resolves `handle/my-cli` at `latest` to verified artifact bytes via the publisher's PDS and storage alone diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..db11d94 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,32 @@ +# impeccable-live-ignore-start +.impeccable/hook.cache.json +.impeccable/hook.pending.json +.impeccable/config.local.json +.impeccable/live/server.json +.impeccable/live/roots.json +.impeccable/live/app-root.json +.impeccable/live/inject-journal.json +.impeccable/live/sessions/ +.impeccable/live/previews/ +.impeccable/live/annotations/ +.impeccable/live/artifacts/ +.impeccable/live/accept-receipts/ +.impeccable/live/locks/ +.impeccable/live/cache/ +.impeccable/live/manual-edit-apply-transaction.json +.impeccable/live/manual-edit-events.jsonl +.impeccable/live/manual-edit-evidence/ +.impeccable/live/pending-manual-edits.json +.impeccable/live/deferred-svelte-component-accepts.json +.impeccable-live.json +.impeccable-live/ +app/.impeccable-live/ +src/.impeccable-live/ +node_modules/.impeccable-live/ +src/lib/impeccable/ImpeccableLiveRoot.svelte +src/lib/impeccable/__runtime.js +src/lib/impeccable/[0-9a-f]*/ +plugins/impeccable-live.client.ts +app/plugins/impeccable-live.client.ts +src/plugins/impeccable-live.client.ts +# impeccable-live-ignore-end diff --git a/web/.impeccable/live/config.json b/web/.impeccable/live/config.json new file mode 100644 index 0000000..140d082 --- /dev/null +++ b/web/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["src/app.html"], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} diff --git a/web/.impeccable/surfaces/web.md b/web/.impeccable/surfaces/web.md index f4cb5ee..89e581f 100644 --- a/web/.impeccable/surfaces/web.md +++ b/web/.impeccable/surfaces/web.md @@ -7,7 +7,7 @@ related_targets: ["web/src/routes"] # Consumer surfaces: release page (destination) + project page (index) -Scope: `web/` SvelteKit routes for `/@publisher/{project}` (index) and `/@publisher/{project}@{selector}` (release destination). Visitor mode: Operate (shifting toward Read when a changelog leads). +Scope: `web/` SvelteKit routes for `/{publisher}/{project}` (index) and `/{publisher}/{project}@{selector}` (release destination). Visitor mode: Operate (shifting toward Read when a changelog leads). ## Audience, job, action diff --git a/web/src/lib/components/ProjectPage.svelte b/web/src/lib/components/ProjectPage.svelte index 57d2e09..53fb9aa 100644 --- a/web/src/lib/components/ProjectPage.svelte +++ b/web/src/lib/components/ProjectPage.svelte @@ -9,9 +9,9 @@ const releases = $derived([...(project.releases ?? [])].reverse()) function successorPath(renamedTo: string): string | null { - // at://did/town.dist.project/new-name -> /@handle/new-name + // at://did/town.dist.project/new-name -> /handle/new-name const name = renamedTo.split('/').pop() - return name ? `/@${handle}/${name}` : null + return name ? `/${handle}/${name}` : null } @@ -46,7 +46,7 @@ {#if project.pointers.length > 0}
{#each project.pointers as ptr (ptr.name)} - + {ptr.name} {ptr.version} @@ -59,7 +59,7 @@ {#if releases.length === 0}

No releases yet. When they arrive, they'll resolve at - /@{handle}/{project.name}@latest. + /{handle}/{project.name}@latest.

{:else}
    @@ -71,7 +71,7 @@ disputed claimant — never resolvable {:else} - + {rel.version} {#if rel.lifecycle.status !== 'active'} {rel.lifecycle.status} diff --git a/web/src/lib/components/ReleasePage.svelte b/web/src/lib/components/ReleasePage.svelte index e8acee6..5bf30be 100644 --- a/web/src/lib/components/ReleasePage.svelte +++ b/web/src/lib/components/ReleasePage.svelte @@ -44,7 +44,7 @@

    - {project.displayName ?? project.name} + {project.displayName ?? project.name} by @{handle}

    {release.version}

    diff --git a/web/src/lib/frontdoor.ts b/web/src/lib/frontdoor.ts index 1ac3eb3..2eab50f 100644 --- a/web/src/lib/frontdoor.ts +++ b/web/src/lib/frontdoor.ts @@ -4,11 +4,11 @@ import { env } from '$env/dynamic/public' const base = () => env.PUBLIC_FRONTDOOR_URL ?? 'http://127.0.0.1:8480' export function artifactURL(handle: string, project: string, selector: string, artifact: string) { - return `${base()}/@${handle}/${project}@${selector}/${encodeURIComponent(artifact)}` + return `${base()}/${handle}/${project}@${selector}/${encodeURIComponent(artifact)}` } export function sumsURL(handle: string, project: string, selector: string) { - return `${base()}/@${handle}/${project}@${selector}/SHA256SUMS` + return `${base()}/${handle}/${project}@${selector}/SHA256SUMS` } export function curlLine(handle: string, project: string, selector: string, artifact: string) { diff --git a/web/src/lib/server/api.ts b/web/src/lib/server/api.ts index 63c8784..4b14e06 100644 --- a/web/src/lib/server/api.ts +++ b/web/src/lib/server/api.ts @@ -13,7 +13,9 @@ async function xrpc(fetchFn: typeof fetch, method: string, params: Record ({ message: resp.statusText })) - error(resp.status === 404 ? 404 : 502, body.message ?? body.error ?? 'read plane error') + // 400 covers unresolvable identifiers: a not-found for the visitor. + const status = resp.status === 404 || resp.status === 400 ? 404 : 502 + error(status, body.message ?? body.error ?? 'read plane error') } return resp.json() as Promise } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index a8f2e3e..68e9b18 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,4 +1,5 @@