Feature: Guest mode MVP — share link, auto-auth, auto-join - #163
Merged
Conversation
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
HexaField
force-pushed
the
feat/guest-mode
branch
from
August 27, 2026 00:47
8c3a80b to
04876af
Compare
Guest users land on /join/<spaceId>?host=<hostUrl> and get: - Silent guest authentication via connectAsGuest() on the hosted node - Automatic space join after boot completes - URL cleanup so reloads don't re-trigger the join flow Components: - guestConnector.ts: parseGuestParams() + createGuestConnector() factory - index.tsx: URL detection, connector switching before render - BootController: consumeGuestJoinTarget() + auto-join after markReady() - SpaceStore: guestLinkFor() builder + copyGuestLink() action - SpaceSettings schema: conditional guest link section with copy button Guest links only appear for web-hosted spaces with a sharedId and known server URL. Local/desktop executors show no guest link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
replaceState('/') changed the browser URL but left the SolidJS router
on a dead route, showing 'Page not found'. Using routeStore.navigate
with the space CID lands the guest directly inside the joined space.
Verified end-to-end: guest URL → silent auth → auto-join → space view.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…on create crypto.randomUUID is only available in secure contexts (HTTPS/localhost). Plain HTTP over LAN/Tailscale broke the app on load. Polyfill uses crypto.getRandomValues (available everywhere) to build a v4 UUID. createSpace captured the DatasetRef before publish, so sharedId stayed empty and the share/guest link sections never appeared. Patch sharedId and sharedUri onto the ref right after publish, before trackDataset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
HexaField
force-pushed
the
feat/guest-mode
branch
from
August 27, 2026 02:06
aae4ddc to
3d8db13
Compare
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
HexaField
force-pushed
the
feat/guest-mode
branch
from
August 27, 2026 02:16
3d8db13 to
902c150
Compare
HexaField
marked this pull request as ready for review
August 27, 2026 03:41
Only conflict was packages/ai-context/src/schemaContext.ts, a single-line generated string. Resolved by taking dev's version of every generated context artefact (schemaContext.ts, context.json, contextData.ts, CLAUDE.md, AGENTS.md, .cursor/rules/we-schema.mdc, copilot-instructions.md) and re-running `pnpm --filter @we/ai-context generate-context`, so the output is derived rather than hand-merged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hich node
Two refusals, both in the new `guestLink` module, and one place that decides
what a guest link *is*.
## It no longer replaces a session somebody already has
`connectAsGuest` writes `ad4m-token` and `ad4m-url` — the two localStorage keys
`Ad4mConnect`'s own constructor reads on every boot. It does not add a guest
session beside an existing one; it replaces it. So an agent who had signed in on
this origin and then clicked a guest link came back, on their next ordinary
visit, as the guest: connected to the inviter's node, with a valid stored token
so the connect UI never appeared, no sign that anything had happened, and no
route back from inside the app. Reachable from a link anybody can paste into a
chat.
A guest link is for somebody with no account, so it now only acts like one for
them. `hasStoredSession()` reads those two keys (never writes them) and the
entry point declines the guest connector when one is there — the link then
behaves as the ordinary share link does, taking an existing agent to the
space's own join gate to decide for themselves, which is what the invite copy
already promises. A token belonging to a *guest* does not count as a session
worth protecting: `connectAsGuest` records one per host under
`ad4m-guest-email-<host>`, so a stored url with a matching marker is a
throwaway identity this same flow created.
That split makes auto-join conditional, so the boot handoff carries it:
`GuestBootTarget` is `{ spaceId, autoJoin }` rather than a bare string.
## The host is checked before anything connects to it
`?host=` was an arbitrary URL taken from a query string and handed straight to
`new Ad4mConnect({ url })`. A link anybody could compose pointed a stranger's
browser at a node of the author's choosing, which then minted them an identity
there — and, before the fix above, made it their standing session.
`isAllowedGuestHost` bounds it: a real executor scheme (http/https/ws/wss), no
credentials smuggled into the authority (`https://node.example.com@evil.example`
reads as node.example.com to anybody skimming it), and no plaintext connection
to a *public* address. Plain HTTP is still accepted for loopback, RFC 1918,
link-local, `.local`, and the 100.64/10 range a tailnet allocates from — there
is no certificate authority for a LAN address, and refusing those would refuse
the setup this feature is demonstrated on.
This does not make a guest link safe. It is an invitation to somebody else's
node by definition, and whose node it is is still unstated at the point of
connecting; naming the host before connecting is a real gap and belongs with
the scoping work the PR already lists as out of scope. What this removes is the
class of link that is not an invitation at all.
The path is also one segment now: `/join/(.+)` read `/join/a/b` as the space
id `a/b`, which is neither an id nor a refusal — it became a join call against
a string nothing could answer.
## One module, both directions
Building a link and reading one back are inverses, and keeping them apart is how
an app ends up handing out links it would itself refuse. They live together in
`app-shell/shared/guestLink.ts` as pure functions over strings, so 21 tests pin
the rule without a browser — including the round trip, which is the property
that matters.
Also here: the `crypto.randomUUID` polyfill moves to its own module, imported
first. Written inline at the top of the entry point it ran *after* every import
in that file had already been evaluated, since ESM hoists imports above
statements — which is not what its position implied. Harmless as it happens
(nothing calls randomUUID at module-evaluation time) but not the guarantee it
looked like.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ailure that can be seen
Three things, all in the one branch the guest flow added to the boot sequence.
**The `/join/` URL was not cleaned.** `routeStore.navigate` forwards to
`@solidjs/router` with no default options, so it *pushes*. `/join/<id>` stayed
in the history: Back landed on a path no route claims, and a reload there re-ran
the entire guest flow. `{ replace: true }`, which is what the comment above it
already claimed was happening.
**A failed join was swallowed and then navigated over.** The catch logged and
fell through to `/space/<id>` regardless. That destination turns out to be
exactly right — `/space/<id>` *is* the join gate, and it states the reason
(`joinError`, matched against this route segment) beside a Join button, so
landing there is how a guest whose join did not complete finds out and retries.
Nothing else on the page could have told them. So the navigation stays
unconditional and is now the documented behaviour rather than an accident;
what changes is that the branch says why.
**Auto-join is now conditional.** Only a session the link itself created joins
without asking — a guest with no account has nothing to decide. An agent who
already had an identity is taken to the same gate to choose, which is what the
ordinary share link does and what the invite copy promises. See the preceding
commit for why the two paths had to separate at all.
The `__weGuestJoinTarget` read moves to `consumeGuestBootTarget` in
`app-shell/shared/guestLink.ts`, so the key and the shape are declared once
rather than spelled out at both ends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot offered
**`guestLinkFor` gave a local executor away as an invitation.** Its comment said
the link "stays empty" for one, but `session.serverUrl()` is set from
`connection.url` for *every* connector, a local one included — so a browser at
`localhost:5173` against a local node published
`http://localhost:5173/join/<cid>?host=http://localhost:12000`, which resolves to
the *recipient's* machine and works for nobody but the person who copied it.
The rule is `buildGuestLink` now, the inverse of the parser the entry point
uses, so the app cannot offer a link it would itself refuse. Both halves have to
be reachable by whoever receives it: a loopback address on the origin or on the
executor means no link, and the settings section hides accordingly. A LAN or
tailnet address still qualifies, since that is a real deployment.
**The link chip's `styles: { 'word-break': 'break-all' }` was dead CSS twice
over.** The node also sets `truncate`, which is `white-space: nowrap` — soft
wrapping is suppressed whatever `word-break` says. And `we-text` now defaults to
`overflow-wrap: anywhere`, which is the DS prop that replaced exactly this
patch; `break-all` is deliberately not offered, because it breaks ordinary prose
too. The share-link block beside it is byte-identical without the line.
**`copyGuestLink` and `spaceList[].guestLink` are documented.** Both were added
to `templateSurface.ts` but not to `fragments/stores.ts`, so the generated
context carried `copyGuestLink(): unknown` and never mentioned the field at all —
a schema author reading the reference could not find a control the shell template
is already using. Regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s told the truth about why Three things found testing the guest flow on a LAN address, which is the only place a guest link can be generated at all — and so the one configuration none of this had run in. **Every copy control failed.** `navigator.clipboard` is secure-context only, so on `http://192.168.1.20:3000` the property is simply absent: `copyShareLink` and `copyGuestLink` threw a TypeError on `undefined.writeText`, caught it, and reported it as a clipboard failure. "Could not copy the link", on both links, with nothing the user could do. The same class of gap as `crypto.randomUUID`, and it hides the same way — localhost *is* a secure context, so it cannot fail in ordinary development. `copyText` in `shared/utils.ts` tries the async API and falls back to a hidden textarea and `document.execCommand('copy')`, which is deprecated, universally supported, and the only thing available outside a secure context. It falls back on a *rejection* too, not just an absence: a denied permission or an unfocused document is still worth the other route. `@we/editor` had solved this privately in `CodeViewer` and keeps its own copy — the editor does not depend on `@we/app-shell` and should not start, since dependencies point inward. Noted there rather than left looking like an oversight. Five tests, in the solid project because the fallback *is* DOM work: the API path does not touch execCommand, absence and rejection both fall back, a double failure reports false rather than claiming success, and the textarea is removed and focus restored. **The link chip pushed the settings panel off the screen.** `flex: '1'` with no `minWidth: '0'` — the trap the DS docs describe. `truncate` sets `white-space: nowrap`, so the chip's min-content width is the entire link and a flex item's automatic minimum is exactly that; it refused every request to compress rather than eliding. Both chips are `flex: '1 1 auto'` with `minWidth: '0'` now, which is what makes the ellipsis reachable. **The name prompt told a guest something untrue.** It explains itself with "your account was set up outside WE, so we do not have a name for you yet" — which is the whole answer for an identity made in the ADAM Launcher or Flux, and false for a guest, whose identity WE minted seconds earlier from the link they clicked. It invites them to go looking for an account they do not have. So the connector says which kind of session it made: `BackendInitResult.guest`, surfaced as `sessionStore.isGuest`, classified and documented. Deliberately not the same question as `host` — an ordinary member of a hosted deployment has a host and is not a guest. What it answers is whether this person chose this identity or a link created one for them, which is the thing that changes what the app should say to them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Guest mode MVP — share link, auto-auth, auto-join
Adds guest mode to WE so somebody with no account can join a hosted space from a single link — no
sign-up, no download.
Guest flow
share link).
/join/<spaceId>?host=<hostUrl>in a browser.holds no session already — switches to the guest connector and calls
connectAsGuest()on thetarget node.
/space/<spaceId>, so a reload does not re-run the flow.Somebody who already has a WE session on that origin takes a different path, and this is
load-bearing rather than an edge case: they keep their own identity and connector, and the link
behaves as the ordinary share link does — it takes them to the space's join gate to decide for
themselves, which is what the invite copy already promises. See "Two refusals" below.
Changes
New:
packages/app-shell/src/shared/guestLink.tsWhat a guest link is, in both directions — the URL shape, and which hosts are acceptable.
parseGuestLink(href)/buildGuestLink({ origin, serverUrl, sharedId })— inverses, in onemodule deliberately: keeping them apart is how an app ends up handing out links it would itself
refuse. There is a round-trip test for exactly that property.
isAllowedGuestHost(url)— see below.writeGuestBootTarget/consumeGuestBootTarget— the one-shot handoff from a web entry point tothe boot flow, so the global's key and shape are declared once rather than spelled out at each
end. Reading removes it, so a remount cannot join twice.
Pure functions over strings, so the rule is pinned by 21 tests rather than by opening a browser.
Two refusals, and why they are the feature rather than hardening
A guest link does not replace a session somebody already has.
connectAsGuestwritesad4m-tokenandad4m-url— the two localStorage keysAd4mConnect's own constructor reads onevery boot (
core.js:11419-11420, written at:11893,11923). It does not add a guest sessionbeside an existing one; it replaces it. So an agent who had signed in on this origin and then
clicked a guest link would come back, on their next ordinary visit, as the guest: connected to the
inviter's node, holding a valid token so the connect UI never appears, with no sign anything had
happened and no route back from inside the app — all from a link anybody can paste into a chat.
hasStoredSession()reads those two keys (and never writes them) and the entry point declines theguest path when one is there. A token belonging to a guest does not count:
connectAsGuestrecords one per host under
ad4m-guest-email-<host>, so a stored url with a matching marker is athrowaway identity this same flow created and there is nothing there to protect.
The host is checked before anything connects to it.
?host=is a URL from a query string handedto
new Ad4mConnect({ url }), so without a check a link anybody could compose points a stranger'sbrowser at a node of the author's choosing, which then mints them an identity there.
isAllowedGuestHostrequires a real executor scheme (http/https/ws/wss), refuses credentialssmuggled into the authority (
https://node.example.com@evil.examplereads as node.example.com toanybody skimming it), and refuses plaintext to a public address. Plain HTTP still works for
loopback, RFC 1918, link-local,
.local, and the 100.64/10 range a tailnet allocates from — there isno certificate authority for a LAN address, and refusing those would refuse the setup this is
developed and demonstrated on.
This does not make a guest link safe: it is an invitation to somebody else's node by definition,
and whose node it is is still unstated at the point of connecting. See "Not in scope".
New:
apps/we-web/src/platform/guestConnector.tsparseGuestParams()— thin wrapper overparseGuestLink(window.location.href).hasStoredSession()— reads ad4m-connect's own keys, never writes them.createGuestConnector(hostUrl)— returns aBackendConnectorusingAd4mConnect.connectAsGuest(). Guest credentials persist in localStorage per host (built intoad4m-connect), so a refresh re-uses the same guest account.
The wildcard
capabilitiespassed to the constructor is required byAd4mConnectOptionsandunread on this path —
connectAsGuestgoes viacreateUser/loginUserand never callsrequestCapability, so what a guest holds is whatever the node grants a logged-in user. It carriesa comment saying so, rather than reading like it is narrowing anything.
administersNode: falseislikewise this app declining to offer node-wide controls, not the executor refusing them.
New:
apps/we-web/src/platform/randomUuidPolyfill.tscrypto.randomUUIDis withheld outside a secure context, and ad4m-connect calls it when minting aguest identity — so without this a guest link fails on exactly the plain-HTTP LAN and tailnet
deployments it is demonstrated on.
getRandomValues, which is the part that has to be unguessable,is available everywhere.
Its own module, imported first, because
importis hoisted above every statement in the importingfile: written inline at the top of the entry point it ran after all of that file's imports had
been evaluated, which is not what its position implied.
Modified:
apps/we-web/src/index.tsxDetects the guest URL before render, and chooses the connector from that and whether a session is
already stored. Writes the boot target either way, carrying
autoJoin.Modified:
BootController.tsxReads the boot target before any async work. Joins only when
autoJoin, then navigates to/space/<spaceId>with{ replace: true }—routeStore.navigateforwards to@solidjs/routerwith no default options, so it pushes, and/join/<id>left in the history meansBack lands on a path no route claims and a reload there re-runs the whole flow.
The navigation is unconditional, including after a failed join. That destination is the right one:
/space/<id>is the join gate, and it states the reason (joinError, matched against thisroute segment) beside a Join button, so landing there is how a guest whose join did not complete
finds out and retries. Nothing else on the page could have told them.
Modified:
SpaceStore.tsxguestLinkFor(ds)— delegates tobuildGuestLink. Both halves have to be reachable by whoeverreceives the link, which is more than "has a server URL":
session.serverUrl()is set from theconnection for every connector, a local executor included, so a browser at
localhost:5173otherwise publishes
http://localhost:5173/join/<cid>?host=http://localhost:12000— a link thatresolves to the recipient's own machine. A loopback address on either half means no link, and the
settings section hides accordingly. A LAN or tailnet address still qualifies.
copyGuestLink(uuid)— clipboard copy with toast feedback.createSpacenow patchesspaceRef.sharedId/sharedUriafter publishing. The proxy'ssharedUrlis not updated in place, so the ref captured at create time stayed empty and bothshareLinkForandguestLinkForreturned''for a space that had just been created as shared.A prerequisite here, but it fixes the ordinary share link too — worth knowing it changes behaviour
outside guest mode.
Modified:
SpaceSettings.tsA conditional guest-link section below the existing share link, hidden when there is no guest link
to offer. Shaped like the share-link block beside it, including leaving the wrapping to
we-text'soverflow-wrapdefault rather than astylesescape hatch.Both link chips are
flex: '1 1 auto'withminWidth: '0'— see below.Modified:
templateSurface.ts,ai-context/src/fragments/stores.tscopyGuestLink,spaceList[].guestLinkandsessionStore.isGuestare each classified (so atemplate may reach them) and described (so the generated reference names them). Both are
required — classification alone produced
copyGuestLink(): unknownin CLAUDE.md and no mention ofthe field at all, leaving a schema author unable to find a control the shell template already uses.
What a LAN deployment turned up
The three below were found by running the flow on
http://192.168.1.157:3000againsthttps://marvin.ad4m.dev. That is not an incidental detail: a guest link cannot be generatedanywhere else — a loopback origin is refused — so it is the one configuration this feature is
always used in, and the one none of it had run in.
Every copy control failed.
navigator.clipboardis secure-context only, so on a plain-HTTPLAN address the property is simply absent: both
copyShareLinkandcopyGuestLinkthrew aTypeErroronundefined.writeText, caught it, and reported it as a clipboard failure — "Could notcopy the link", on both links, with nothing the user could do about it. The same class of gap as
crypto.randomUUID, and it hides the same way: localhost is a secure context, so it cannot failin ordinary development.
copyTextinshared/utils.tstries the async API and falls back to a hidden textarea anddocument.execCommand('copy')— deprecated, universally supported, and the only thing availableoutside a secure context. It falls back on a rejection too, not merely an absence: a denied
permission or an unfocused document is still worth the other route.
@we/editor'sCodeViewerhadsolved this privately and keeps its own copy, because the editor does not depend on
@we/app-shelland should not start; that duplication is noted there rather than left looking like an oversight.
The link chip pushed the settings panel off the screen.
flex: '1'with nominWidth: '0'—the trap the DS docs describe.
truncatesetswhite-space: nowrap, so the chip's min-contentwidth is the entire link, and a flex item's automatic minimum is exactly that: it refused every
request to compress rather than eliding.
The name prompt told a guest something untrue. It explains itself with "your account was set up
outside WE, so we do not have a name for you yet" — the whole answer for an identity made in the
ADAM Launcher or Flux, and false for a guest, whose identity WE minted seconds earlier from the link
they clicked. It invites them to go looking for an account they do not have.
So the connector reports which kind of session it made:
BackendInitResult.guest, surfaced assessionStore.isGuest. Deliberately not the same question ashost— an ordinary member of ahosted deployment has a host and is not a guest. What it answers is whether this person chose this
identity or a link created one for them, which is what changes how the app should explain itself.
The prompt branches its one sentence on it.
Also present
.codegraph/.gitignoreand.graphcoder/.gitignore— local tooling directories, ignored so theirtransient files never show up in
git status. Unrelated to guest mode.Not in scope (iteration)
screen at boot, before the connector runs. The scheme and authority checks above bound what a
guest link can be; they do not tell the guest where they are going. This is the gap most worth
closing next.
grants a logged-in user; that is a node-side question.
appInfo.urldiverges between the two connectors — the guest one derives it fromlocation,ad4mConnector.tsstill hardcodes'ad4m.weco.io'. Changing the normal connector's app identitycould affect existing capability grants, so it is worth deciding deliberately rather than as a
side effect of this branch.
for a deployment — the link would resolve to the recipient's own machine — but for anyone testing
locally the section simply is not there. Showing it with a note explaining what is missing would
be kinder, and is a change to the share section rather than to guest mode.
sessionStore.isGuestcurrently only changes one sentence. It is the right place to hang morelater: what "log out" means for an identity with no other way back, and any prompt to turn a guest
session into an account.
Test plan
Suites, at the tip of the branch, all green.
app-shell677 tests across 51 files (+26 new, +6todo) ·
validate:schemas32 schemas, no issues · typecheck clean onapp-shell,template-shell,app-webandai-context· eslint clean on every changed file.New coverage —
guestLink.test.ts: TLS hosts accepted anywhere · plain HTTP accepted only onloopback/RFC1918/link-local/
.local/.ts.net/CGNAT and refused to a public address ·javascript:,data:,file:and unparseable hosts refused · credentials in the authorityrefused · the 172.16/12 range bounded at both ends · the canonical link, a trailing slash, a
missing host, a wrong path, extra path segments, and an undecodable segment · the build/parse round
trip on both a hosted and a LAN deployment · loopback refused on either half of a built link · the
boot handoff consumed exactly once.
New coverage —
copyText.test.ts: the async API used where present and the legacy path leftalone · fallback on absence (what a non-secure context actually gives you) and on rejection · a
double failure reported as
falserather than claimed as success · the textarea removed and focusrestored. In the jsdom project, because the fallback is DOM work.
Confirmed non-vacuous by reverting the fix and watching them fail: restoring the original
/join/(.+)parser with no host validation fails 3 of the new tests, including the/join/a/bcase.Verified by hand, on
http://192.168.1.157:3000againsthttps://marvin.ad4m.dev:lands inside; a reload does not re-run the flow.
intact and the browser lands on the space's join gate.
this space. Check the link and try again." The gate cannot say more before the attempt: a
well-formed CID that is not joinable is indistinguishable from one you simply have not joined.
Still to check:
minWidth: '0'fix and appeared to line-break on hover; nothing in that node carries ahoverPropsandwe-textsets notitle, so the mechanism is unexplained and may simplyhave been a consequence of the overflow. Worth a look after the fix rather than assuming.