From ba7022a9bf8e7d655a89cbd9398b9cfcdf933331 Mon Sep 17 00:00:00 2001 From: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) Date: Fri, 31 Jul 2026 06:15:08 +0000 Subject: [PATCH] Make public deep links space-aware Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) --- packages/ui/README.md | 6 ++++++ packages/ui/src/edge/target.test.ts | 12 ++++++++++++ packages/ui/src/edge/target.ts | 7 +++++++ packages/ui/src/lib/asks.test.ts | 20 +++++++++++++++----- packages/ui/src/lib/session.svelte.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- packages/ui/src/lib/session.test.ts | 37 +++++++++++++++++++++++++++++++++++-- packages/ui/src/lib/space.test.ts | 31 +++++++++++++++++++++++++++++-- packages/ui/src/lib/space.ts | 44 +++++++++++++++++++++++++++++++++++--------- packages/ui/src/routes/+layout.svelte | 23 ++++++++++++++++------- packages/ui/src/lib/components/NewGoal.svelte | 8 ++++---- packages/ui/src/lib/components/Rail.svelte | 17 +++++++++-------- packages/ui/src/lib/components/SpacePicker.svelte | 8 ++++++++ packages/ui/src/routes/p/[project]/settings/+page.svelte | 6 +++--- 13 file(s) changed, 233 insertion(s)(+), 41 deletion(s)(-) diff --git a/packages/ui/README.md b/packages/ui/README.md --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -51,6 +51,12 @@ from then on. Reading a space needs no account at all: the picker also accepts a bare space URI, and includes the fixture (a built-in demo space) — both work signed out. +Public links carry the space record's AT URI in a `space` query parameter, so a logged-out visitor +can open the linked public space directly. Opening does not join the space: after sign-in, the +existing join bookmark is written only for an active member. Goal links minted before this parameter +remain recoverable because the public goal record names its space. Older project and space-wide list +links are ambiguous and return to the picker instead of guessing from this browser's history. + Every read is `com.atproto.repo.listRecords` against a member's own PDS. Records are cached in IndexedDB, so a warm reload costs one `getLatestCommit` per member and no rescan. While the tab is visible it polls every ten seconds; hidden, it stops and catches up when it becomes visible again. diff --git a/packages/ui/src/edge/target.test.ts b/packages/ui/src/edge/target.test.ts --- a/packages/ui/src/edge/target.test.ts +++ b/packages/ui/src/edge/target.test.ts @@ -7,6 +7,7 @@ const ARTIFACT = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.artifact/plan-abc' const REQUEST = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.artifactRequest/3mrvv5kltnp2o' +const SPACE = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.space/3mrvspace' describe('a goal deep link', () => { it('reconstructs the AT URI the route was minted from', () => { @@ -87,5 +88,16 @@ expect(canonicalUrl(at('/p/radial?frame'), previewTarget(at('/p/radial?frame')))).toBe( 'https://radl.app/p/radial', ) + }) + + it('retains only valid space navigation context', () => { + const valid = at(`/p/radial?space=${encodeURIComponent(SPACE)}&v=2&utm_source=chat`) + expect(canonicalUrl(valid, previewTarget(valid))).toBe( + `https://radl.app/p/radial?space=${encodeURIComponent(SPACE)}`, + ) + for (const value of ['nope', GOAL, 'https://example.com']) { + const malformed = at(`/p/radial?space=${encodeURIComponent(value)}`) + expect(canonicalUrl(malformed, previewTarget(malformed))).toBe('https://radl.app/p/radial') + } }) }) diff --git a/packages/ui/src/edge/target.ts b/packages/ui/src/edge/target.ts --- a/packages/ui/src/edge/target.ts +++ b/packages/ui/src/edge/target.ts @@ -14,6 +14,7 @@ */ import { COLLECTIONS } from '@radial/core' +import { parseAtUri } from '@radial/atproto' export type PreviewTarget = | { kind: 'goal'; goal: string; unit?: string } @@ -72,5 +73,11 @@ export function canonicalUrl(url: URL, target: PreviewTarget | undefined): string { const canonical = new URL(url.pathname, url.origin) if (target?.kind === 'goal' && target.unit) canonical.searchParams.set('unit', target.unit) + const space = url.searchParams.get('space') + try { + if (space && parseAtUri(space).collection === COLLECTIONS.space) canonical.searchParams.set('space', space) + } catch { + // Navigation context is untrusted input. A malformed value is not canonical. + } return canonical.toString() } diff --git a/packages/ui/src/lib/asks.test.ts b/packages/ui/src/lib/asks.test.ts --- a/packages/ui/src/lib/asks.test.ts +++ b/packages/ui/src/lib/asks.test.ts @@ -158,9 +158,14 @@ // drawer opens on what is being read rather than following the tip of whatever lands next. const auto = ask(space.index, AUTO_REVIEW) if (!auto.pinned) throw new Error('fixture ask lost its version') - expect(unitHref(goalHref(goal(IGNORED)), auto.pinned.unit, auto.pinned.version)).toMatch( - /^\/g\/[^/]+\/[^/]+\?unit=.+&v=1$/, + const href = new URL( + unitHref(goalHref(goal(IGNORED)), auto.pinned.unit, auto.pinned.version), + 'https://radl.app', ) + expect(href.pathname).toMatch(/^\/g\/[^/]+\/[^/]+$/) + expect(href.searchParams.get('space')).toBe(space.uri) + expect(href.searchParams.get('unit')).toBe(auto.pinned.unit.key) + expect(href.searchParams.get('v')).toBe('1') const claimed = ask(space.index, CLAIMED_REVIEW) const project = space.index.projects.find( @@ -169,9 +174,14 @@ if (!claimed.pinned || !project) throw new Error('fixture ask lost its version') // Its third version, and the System page rather than a goal's. expect(claimed.pinned.version.version).toBe(3) - expect(unitHref(systemHref(project), claimed.pinned.unit, claimed.pinned.version)).toBe( - `${systemHref(project)}?unit=${encodeURIComponent(claimed.pinned.unit.key)}&v=3`, + const systemUnit = new URL( + unitHref(systemHref(project), claimed.pinned.unit, claimed.pinned.version), + 'https://radl.app', ) + expect(systemUnit.pathname).toBe('/p/radial-ng/system') + expect(systemUnit.searchParams.get('space')).toBe(space.uri) + expect(systemUnit.searchParams.get('unit')).toBe(claimed.pinned.unit.key) + expect(systemUnit.searchParams.get('v')).toBe('3') }) it('opens a reply in the thread, because that is where the box to answer it is', () => { @@ -180,7 +190,7 @@ const reply = ask(space.index, MESSAGE_ASK) expect(reply.pinned).toBeUndefined() expect(isGoalView(reply.target)).toBe(true) - expect(goalHref({ uri: reply.target.target.uri })).toBe(goalHref(goal(QUEUE))) + expect(goalHref({ uri: reply.target.target.uri }, space.uri)).toBe(goalHref(goal(QUEUE))) }) }) diff --git a/packages/ui/src/lib/session.svelte.ts b/packages/ui/src/lib/session.svelte.ts --- a/packages/ui/src/lib/session.svelte.ts +++ b/packages/ui/src/lib/session.svelte.ts @@ -17,7 +17,14 @@ // - **Opening a space is the only await.** Hydration, the cold scan, and handle resolution all // finish before `status` becomes `ready`, so no view ever renders a half-loaded space. -import { COLLECTIONS, materialize, type MaterializedIndex, type StoredRecord } from '@radial/core' +import { + COLLECTIONS, + materialize, + validateRecord, + type GoalRecord, + type MaterializedIndex, + type StoredRecord, +} from '@radial/core' import { FetchRepoTransport, parseAtUri, type RepoReadTransport } from '@radial/atproto' import { RepoPoller, SpaceIngestor } from '@radial/ingest' import { buildDirectory } from './directory.js' @@ -196,6 +203,52 @@ return } await openSpace(last, options) +} + +/** Resolve a shareable URL before consulting observer-local history. */ +export async function bootstrap(url: URL, options: OpenOptions = {}): Promise { + session.recents = readRecents() + session.status = 'opening' + const explicit = url.searchParams.get('space') + if (explicit !== null) { + stop() + session.space = undefined + await openSpace(explicit, options) + return + } + + const match = /^\/g\/(did:[^/]+)\/([^/]+)\/?$/.exec(url.pathname) + if (match) { + stop() + session.space = undefined + const goalUri = `at://${match[1]}/${COLLECTIONS.goal}/${match[2]}` + try { + parseAtUri(goalUri) + const identities = options.identities ?? new Identities() + const transport = options.transport?.(identities) ?? new FetchRepoTransport(identities.pdsResolver) + const record = await transport.getRecord(goalUri) + const validated = validateRecord(COLLECTIONS.goal, record.value) + if (!validated.success) { + throw new Error(validated.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')) + } + await openSpace((validated.value as GoalRecord).space.uri, options) + } catch (error) { + session.status = 'choosing' + session.error = `Could not resolve the goal link: ${message(error)}` + } + return + } + + // Non-root routes without navigation context are ambiguous. In particular, a project name is + // only unique within one space, so a remembered fold must not be interpreted as the link's. + if (url.pathname !== '/') { + stop() + session.space = undefined + session.status = 'choosing' + session.error = 'This older link does not say which space to open. Enter its space URI to continue.' + return + } + await restore(options) } /** Leave the space without forgetting it: back to the picker. */ diff --git a/packages/ui/src/lib/session.test.ts b/packages/ui/src/lib/session.test.ts --- a/packages/ui/src/lib/session.test.ts +++ b/packages/ui/src/lib/session.test.ts @@ -5,7 +5,9 @@ import { FakePds, latestVersions, memoryStorage } from './fake-pds.js' import { memoryDatabase, type SpaceDatabase } from './idb.js' import { Identities } from './identity.js' -import { openSpace, session, settle, stop, sync } from './session.svelte.js' +import { + bootstrap, openSpace, session, settle, stop, sync, type OpenOptions, +} from './session.svelte.js' import { openSpaceStores } from './store.js' import { goalsOf, isEnded, projectByName, unitsOf } from './space.js' @@ -38,7 +40,9 @@ } const open = (harness: Harness): Promise => - openSpace(fixture.spaceUri, { + openSpace(fixture.spaceUri, options(harness)) + +const options = (harness: Harness): OpenOptions => ({ identities: new Identities({ fetcher: harness.network.fetch, storage: harness.storage }), transport: (identities) => new FetchRepoTransport(identities.pdsResolver, harness.network.fetch), openStores: (uri) => openSpaceStores(uri, async () => harness.database), @@ -151,6 +155,35 @@ await open(live) expect(session.status).toBe('failed') expect(session.error).toContain('Space record not found') + }) +}) + +describe('opening a deep link', () => { + it('opens an explicit public space without requiring an account', async () => { + const live = harness() + await bootstrap( + new URL(`/p/radial?space=${encodeURIComponent(fixture.spaceUri)}`, 'https://radl.app'), + options(live), + ) + expect(session.status).toBe('ready') + expect(session.space?.uri).toBe(fixture.spaceUri) + }) + + it('recovers an older goal link from the public goal record', async () => { + const live = harness() + const goal = currentVersions().find((record) => record.collection === COLLECTIONS.goal) + if (!goal) throw new Error('fixture goal missing') + const parts = parseAtUri(goal.uri) + await bootstrap(new URL(`/g/${parts.did}/${parts.rkey}`, 'https://radl.app'), options(live)) + expect(session.status).toBe('ready') + expect(session.space?.uri).toBe(fixture.spaceUri) + }) + + it('does not interpret an ambiguous older project link against a remembered space', async () => { + await bootstrap(new URL('/p/radial', 'https://radl.app')) + expect(session.status).toBe('choosing') + expect(session.space).toBeUndefined() + expect(session.error).toContain('which space') }) }) diff --git a/packages/ui/src/lib/space.test.ts b/packages/ui/src/lib/space.test.ts --- a/packages/ui/src/lib/space.test.ts +++ b/packages/ui/src/lib/space.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { didOf, rkeyOf } from './format.js' import { buildFixtureSpace } from './fixture.js' -import { goalByRoute, goalHref, goalsOf, isEnded, projectByName, unitsOf } from './space.js' +import { + goalByRoute, goalHref, goalsOf, isEnded, projectByName, projectHref, settingsHref, + spaceHref, systemHref, unitHref, unitsOf, withSpace, +} from './space.js' import { capturedFrom, claimExpiry, @@ -52,13 +55,37 @@ for (const goal of space.index.goals) { const href = goalHref(goal) expect(goalByRoute(space.index, didOf(goal.target.uri), rkeyOf(goal.target.uri))).toBe(goal) - expect(href).toBe(`/g/${goal.target.did}/${goal.target.rkey}`) + expect(href).toBe(`/g/${goal.target.did}/${goal.target.rkey}?space=${encodeURIComponent(space.uri)}`) } }) it('finds a project by the name the rail shows', () => { expect(projectByName(space.index, 'radial-ng')?.target.value.name).toBe('radial-ng') expect(projectByName(space.index, 'nope')).toBeUndefined() + }) + + it('makes every space-scoped route self-sufficient', () => { + const project = space.index.projects[0] + const goal = space.index.goals[0] + if (!project || !goal) throw new Error('fixture routes missing') + for (const href of [ + goalHref(goal), projectHref(project), systemHref(project), settingsHref(project), + spaceHref(space), spaceHref(space, '/awaiting'), + ]) { + expect(new URL(href, 'https://radl.app').searchParams.getAll('space')).toEqual([space.uri]) + } + }) + + it('composes space, unit, and version parameters without duplicates', () => { + const unit = { key: 'at://did:plc:abc/com.disnetdev.radial.artifact/a' } + const url = new URL( + unitHref(withSpace('/g/did:plc:abc/rk?space=old', space.uri), unit, { version: 3 }), + 'https://radl.app', + ) + expect(url.searchParams.get('space')).toBe(space.uri) + expect(url.searchParams.get('unit')).toBe(unit.key) + expect(url.searchParams.get('v')).toBe('3') + expect(url.searchParams.getAll('space')).toHaveLength(1) }) }) diff --git a/packages/ui/src/lib/space.ts b/packages/ui/src/lib/space.ts --- a/packages/ui/src/lib/space.ts +++ b/packages/ui/src/lib/space.ts @@ -100,27 +100,44 @@ // A goal's identity is its AT URI; the route carries the two parts that reconstruct it, which keeps // URLs legible and survives the space being re-ingested from scratch. -export const goalHref = (goal: GoalView | { uri: string }): string => { - const uri = 'uri' in goal ? goal.uri : (goal as GoalView).target.uri - return `/g/${didOf(uri)}/${rkeyOf(uri)}` +export function withSpace(href: string, spaceUri?: string): string { + if (!spaceUri) return href + const [path, query = ''] = href.split('?') + const params = new URLSearchParams(query) + params.set('space', spaceUri) + const encoded = params.toString() + return `${path}${encoded ? `?${encoded}` : ''}` } -export const goalUriHref = (uri: string): string => `/g/${didOf(uri)}/${rkeyOf(uri)}` +export const goalHref = (goal: GoalView | { uri: string }, spaceUri?: string): string => { + const uri = 'uri' in goal ? goal.uri : (goal as GoalView).target.uri + const inferred = 'target' in goal ? goal.target.value.space.uri : undefined + return withSpace(`/g/${didOf(uri)}/${rkeyOf(uri)}`, spaceUri ?? inferred) +} + +export const goalUriHref = (uri: string, spaceUri?: string): string => { + const href = `/g/${didOf(uri)}/${rkeyOf(uri)}` + return spaceUri ? withSpace(href, spaceUri) : href +} export const projectHref = (project: ProjectView): string => - `/p/${encodeURIComponent(project.name)}` + withSpace(`/p/${encodeURIComponent(project.name)}`, project.target.value.space.uri) -export const systemHref = (project: ProjectView): string => `${projectHref(project)}/system` +export const systemHref = (project: ProjectView): string => + withSpace(`/p/${encodeURIComponent(project.name)}/system`, project.target.value.space.uri) /** * A project's own settings: the three things about it that can change (name, remote, default branch — * each a standalone `editProject` record rather than a rewrite of the strongref-pinned project), which * types auto-review, and whether the project is archived at all. */ -export const settingsHref = (project: ProjectView): string => `${projectHref(project)}/settings` +export const settingsHref = (project: ProjectView): string => + withSpace(`/p/${encodeURIComponent(project.name)}/settings`, project.target.value.space.uri) /** The space's own shape: members, the artifact-type registry, projects. */ export const SPACE_HREF = '/space' +export const spaceHref = (space: Pick, path = SPACE_HREF): string => + withSpace(path, space.uri) /** * Deep link that opens one unit's drawer: what a cross-goal row points at. @@ -132,8 +149,17 @@ * A key is all it takes, so a write that has just landed can be linked before anything has folded * it into a `UnitView`: a unit with nothing landed is keyed by its request's URI (§3.7). */ -export const unitHref = (base: string, unit: Pick, version?: { version: number }): string => - `${base}?unit=${encodeURIComponent(unit.key)}${version ? `&v=${version.version}` : ''}` +export function unitHref( + base: string, + unit: Pick, + version?: { version: number }, +): string { + const [path, query = ''] = base.split('?') + const params = new URLSearchParams(query) + params.set('unit', unit.key) + if (version) params.set('v', String(version.version)) + return `${path}?${params.toString()}` +} export interface Crumb { label: string diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -20,7 +20,7 @@ import Skeleton from '$lib/components/Skeleton.svelte' import SpacePicker from '$lib/components/SpacePicker.svelte' import Toast from '$lib/components/Toast.svelte' - import { restore, session, stop, watchVisibility } from '$lib/session.svelte.js' + import { bootstrap, session, stop, watchVisibility } from '$lib/session.svelte.js' import { goalByRoute, projectByName, @@ -32,21 +32,23 @@ import { closeMenu, ui } from '$lib/ui.svelte.js' const { children } = $props() + let bootstrapped = $state(false) - // Reopen whatever this browser profile was last looking at. Everything below `space` is gated on - // it, so no view ever renders against a half-loaded space — and a warm profile pays one - // `getLatestCommit` per member rather than a full rescan (see `store.ts`). + // Resolve the requested link before falling back to this browser profile's last space. Everything + // below `space` is gated on it, so no view ever renders against a half-loaded or wrong space. onMount(() => { const unwatch = watchVisibility() - void restore() + session.status = 'opening' // Independent of the space: an account is a property of the browser profile, and a tab that has // not chosen a space yet can still be signed in. `initAuth` also finishes a sign-in coming back // from the authorization server, which lands on the app root rather than where the human left — // so it hands back that page and we put them on it. - void initAuth().then((path) => { + void initAuth().then(async (path) => { if (path && path !== `${location.pathname}${location.search}`) { - void goto(path, { replaceState: true }) + await goto(path, { replaceState: true }) } + await bootstrap(new URL(location.href)) + bootstrapped = true }) return () => { unwatch() @@ -61,6 +63,13 @@ const space = $derived(session.space) const route = $derived(page.route.id ?? '') + + // A client-side navigation can carry a link for another space without remounting this layout. + // Re-run the same coordinator only when its explicit target differs from the open fold. + $effect(() => { + const target = page.url.searchParams.get('space') + if (bootstrapped && target && target !== session.space?.uri) void bootstrap(page.url) + }) // A draft belongs to the view that opened it: leaving that view abandons it rather than carrying // a half-written goal to a page that has nowhere to draw it. The ⊕ menu goes with it — it offers diff --git a/packages/ui/src/lib/components/NewGoal.svelte b/packages/ui/src/lib/components/NewGoal.svelte --- a/packages/ui/src/lib/components/NewGoal.svelte +++ b/packages/ui/src/lib/components/NewGoal.svelte @@ -9,7 +9,7 @@ newGoalArgs, rememberGoalProject, } from '$lib/goal.js' - import { goalUriHref, projectByUri, SPACE_HREF, type Space } from '$lib/space.js' + import { goalUriHref, projectByUri, spaceHref, type Space } from '$lib/space.js' import { ui } from '$lib/ui.svelte.js' import { write } from '$lib/write.js' import Glyph from './Glyph.svelte' @@ -96,7 +96,7 @@ closeDraft() // `write` has already folded the PDS-acknowledged record, so the goal is in the index this // navigates into — no empty page waiting for the next tick. - await goto(goalUriHref(result.primary.uri)) + await goto(goalUriHref(result.primary.uri, space.uri)) } catch (failure) { error = failure instanceof Error ? failure.message : String(failure) } finally { @@ -125,12 +125,12 @@ {#if space.index.projects.length === 0}

This space has no projects yet. An admin writes the first one, on - the space's own page. + the space's own page.

{:else}

Every project in this space is archived. - Add one on the space's own page. + Add one on the space's own page.

{/if}
diff --git a/packages/ui/src/lib/components/Rail.svelte b/packages/ui/src/lib/components/Rail.svelte --- a/packages/ui/src/lib/components/Rail.svelte +++ b/packages/ui/src/lib/components/Rail.svelte @@ -11,7 +11,7 @@ isEnded, liveProjects, projectHref, - SPACE_HREF, + spaceHref, systemHref, unitsOf, type Space, @@ -55,10 +55,10 @@ }) const smart = $derived([ - { href: '/me', label: 'For me', glyph: 'star', color: 'var(--accent-mark)', count: counts.me }, - { href: '/awaiting', label: 'Awaiting input', glyph: 'chat', color: 'var(--warn-mark)', count: counts.awaiting }, - { href: '/inflight', label: 'With an agent', glyph: 'bolt', color: 'var(--accent-mark)', count: counts.moving }, - { href: '/logbook', label: 'Logbook', glyph: 'book', color: 'var(--ok-mark)', count: counts.logbook }, + { href: spaceHref(space, '/me'), label: 'For me', glyph: 'star', color: 'var(--accent-mark)', count: counts.me }, + { href: spaceHref(space, '/awaiting'), label: 'Awaiting input', glyph: 'chat', color: 'var(--warn-mark)', count: counts.awaiting }, + { href: spaceHref(space, '/inflight'), label: 'With an agent', glyph: 'bolt', color: 'var(--accent-mark)', count: counts.moving }, + { href: spaceHref(space, '/logbook'), label: 'Logbook', glyph: 'book', color: 'var(--ok-mark)', count: counts.logbook }, ]) const members = $derived(space.index.members.filter((member) => member.active)) @@ -84,7 +84,8 @@ ).length } - const current = (href: string): 'page' | undefined => (page.url.pathname === href ? 'page' : undefined) + const current = (href: string): 'page' | undefined => + (page.url.pathname === href.split('?')[0] ? 'page' : undefined)