diff --git a/packages/ui/README.md b/packages/ui/README.md index d41b2a3..1e884c7 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -51,6 +51,12 @@ repo, so the first time you open it by URI the app writes your own `join` record 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 index e4d3b39..62fcb8d 100644 --- a/packages/ui/src/edge/target.test.ts +++ b/packages/ui/src/edge/target.test.ts @@ -7,6 +7,7 @@ const GOAL = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.goal/3m 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', () => { @@ -88,4 +89,15 @@ describe('the canonical URL', () => { '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 index 101b9a4..6134316 100644 --- 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 @@ function decodeSegment(segment: string): string { 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 index 7140a4c..da50966 100644 --- a/packages/ui/src/lib/asks.test.ts +++ b/packages/ui/src/lib/asks.test.ts @@ -158,9 +158,14 @@ describe('where a row goes', () => { // 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 @@ describe('where a row goes', () => { 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 @@ describe('where a row goes', () => { 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/components/NewGoal.svelte b/packages/ui/src/lib/components/NewGoal.svelte index f172e8d..4ed6ac5 100644 --- 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 index 945d9a4..e03379a 100644 --- 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)