Skip to content

Feature: Guest mode MVP — share link, auto-auth, auto-join - #163

Merged
jhweir merged 10 commits into
devfrom
feat/guest-mode
Aug 27, 2026
Merged

Feature: Guest mode MVP — share link, auto-auth, auto-join#163
jhweir merged 10 commits into
devfrom
feat/guest-mode

Conversation

@HexaField

@HexaField HexaField commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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

  1. A space owner copies the guest link from Space Settings (a new section below the existing
    share link).
  2. The guest opens /join/<spaceId>?host=<hostUrl> in a browser.
  3. WE recognises the guest URL, checks the host is one it will connect to, and — if this browser
    holds no session already
    — switches to the guest connector and calls connectAsGuest() on the
    target node.
  4. After silent auth, the boot controller joins the target space and replaces the URL with
    /space/<spaceId>, so a reload does not re-run the flow.
  5. The guest lands directly in the space.

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.ts

What a guest link is, in both directions — the URL shape, and which hosts are acceptable.

  • parseGuestLink(href) / buildGuestLink({ origin, serverUrl, sharedId }) — inverses, in one
    module 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 to
    the 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. connectAsGuest writes
ad4m-token and ad4m-url — the two localStorage keys Ad4mConnect's own constructor reads on
every boot (core.js:11419-11420, written at :11893,11923). 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 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 the
guest path when one is there. A token belonging to a guest does not count: 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 and there is nothing there to protect.

The host is checked before anything connects to it. ?host= is a URL from a query string handed
to new Ad4mConnect({ url }), so without a check a link anybody could compose points a stranger's
browser at a node of the author's choosing, which then mints them an identity there.
isAllowedGuestHost requires a real executor scheme (http/https/ws/wss), refuses credentials
smuggled into the authority (https://node.example.com@evil.example reads as node.example.com to
anybody 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 is
no 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.ts

  • parseGuestParams() — thin wrapper over parseGuestLink(window.location.href).
  • hasStoredSession() — reads ad4m-connect's own keys, never writes them.
  • createGuestConnector(hostUrl) — returns a BackendConnector using
    Ad4mConnect.connectAsGuest(). Guest credentials persist in localStorage per host (built into
    ad4m-connect), so a refresh re-uses the same guest account.

The wildcard capabilities passed to the constructor is required by Ad4mConnectOptions and
unread on this path
connectAsGuest goes via createUser/loginUser and never calls
requestCapability, so what a guest holds is whatever the node grants a logged-in user. It carries
a comment saying so, rather than reading like it is narrowing anything. administersNode: false is
likewise this app declining to offer node-wide controls, not the executor refusing them.

New: apps/we-web/src/platform/randomUuidPolyfill.ts

crypto.randomUUID is withheld outside a secure context, and ad4m-connect calls it when minting a
guest 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 import is hoisted above every statement in the importing
file: 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.tsx

Detects 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.tsx

Reads the boot target before any async work. Joins only when autoJoin, then navigates to
/space/<spaceId> with { replace: true }routeStore.navigate forwards to
@solidjs/router with no default options, so it pushes, and /join/<id> left in the history means
Back 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 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.

Modified: SpaceStore.tsx

  • guestLinkFor(ds) — delegates to buildGuestLink. Both halves have to be reachable by whoever
    receives the link, which is more than "has a server URL": session.serverUrl() is set from the
    connection for every connector, a local executor included, so a browser at localhost:5173
    otherwise publishes http://localhost:5173/join/<cid>?host=http://localhost:12000 — a link that
    resolves 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.
  • createSpace now patches spaceRef.sharedId / sharedUri after publishing. The proxy's
    sharedUrl is not updated in place, so the ref captured at create time stayed empty and both
    shareLinkFor and guestLinkFor returned '' 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.ts

A 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's overflow-wrap default rather than a styles escape hatch.

Both link chips are flex: '1 1 auto' with minWidth: '0' — see below.

Modified: templateSurface.ts, ai-context/src/fragments/stores.ts

copyGuestLink, spaceList[].guestLink and sessionStore.isGuest are each classified (so a
template may reach them) and described (so the generated reference names them). Both are
required — classification alone produced copyGuestLink(): unknown in CLAUDE.md and no mention of
the 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:3000 against
https://marvin.ad4m.dev. That is not an incidental detail: a guest link cannot be generated
anywhere 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.clipboard is secure-context only, so on a plain-HTTP
LAN address the property is simply absent: both 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 about it. 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') — deprecated, universally supported, and the only thing available
outside 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's CodeViewer had
solved this privately and keeps its own copy, because the editor does not depend on @we/app-shell
and 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 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.

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 as
sessionStore.isGuest. 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 what changes how the app should explain itself.
The prompt branches its one sentence on it.

Also present

.codegraph/.gitignore and .graphcoder/.gitignore — local tooling directories, ignored so their
transient files never show up in git status. Unrelated to guest mode.

Not in scope (iteration)

  • Naming the host before connecting. The honest answer to "whose node am I about to join?" is a
    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.
  • Guest → full account upgrade flow.
  • Permission scoping / read-only guest mode. Note that a guest currently holds whatever the node
    grants a logged-in user; that is a node-side question.
  • Guest session management (expiry, revocation).
  • appInfo.url diverges between the two connectors — the guest one derives it from location,
    ad4mConnector.ts still hardcodes 'ad4m.weco.io'. Changing the normal connector's app identity
    could affect existing capability grants, so it is worth deciding deliberately rather than as a
    side effect of this branch.
  • A developer on localhost sees no guest-link section and no reason why. The refusal is right
    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.isGuest currently only changes one sentence. It is the right place to hang more
    later: 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-shell 677 tests across 51 files (+26 new, +6
todo) · validate:schemas 32 schemas, no issues · typecheck clean on app-shell,
template-shell, app-web and ai-context · eslint clean on every changed file.

New coverage — guestLink.test.ts: TLS hosts accepted anywhere · plain HTTP accepted only on
loopback/RFC1918/link-local/.local/.ts.net/CGNAT and refused to a public address ·
javascript:, data:, file: and unparseable hosts refused · credentials in the authority
refused · 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 left
alone · fallback on absence (what a non-secure context actually gives you) and on rejection · a
double failure reported as false rather than claimed as success · the textarea removed and focus
restored. 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/b case.

Verified by hand, on http://192.168.1.157:3000 against https://marvin.ad4m.dev:

  • A guest link opened with no prior WE session — account created on the node, space joined,
    lands inside; a reload does not re-run the flow.
  • The same link opened in a browser that already has a session — the existing identity is
    intact and the browser lands on the space's join gate.
  • Space Settings on a localhost origin — the guest link section is absent, as designed.
  • A guest link with a bad CID — lands on the join gate, and pressing Join reports "Couldn't join
    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.
  • Both copy buttons on a plain-HTTP LAN origin (this is what found the clipboard bug).

Still to check:

  • A guest link opened from a second machine rather than an incognito window on the same one.
  • Whether the link chip still reflows on hover. It pushed the panel sideways before the
    minWidth: '0' fix and appeared to line-break on hover; nothing in that node carries a
    hoverProps and we-text sets no title, so the mechanism is unexplained and may simply
    have been a consequence of the overflow. Worth a look after the fix rather than assuming.
  • The guest-specific name prompt copy, which was written after the manual pass that found it.

@netlify

netlify Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit ec46f59
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a90660f5ad41b000823b413
😎 Deploy Preview https://deploy-preview-163--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

HexaField and others added 4 commits August 27, 2026 12:05
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>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@HexaField
HexaField marked this pull request as ready for review August 27, 2026 03:41
@HexaField
HexaField requested a review from jhweir as a code owner August 27, 2026 03:41
jhweir and others added 5 commits August 27, 2026 15:55
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>
@jhweir
jhweir merged commit 7af0098 into dev Aug 27, 2026
9 checks passed
@jhweir jhweir changed the title feat: guest mode MVP — share link, auto-auth, auto-join Feature: Guest mode MVP — share link, auto-auth, auto-join Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants