Action links
Design of record: QR Coupon Journeys (MeshWeaver.Plugins#2066). This page describes what is built (stage 2a, MeshWeaver.Plugins#2060), and what is not built yet. The platform contract it consumes is core's Action-Link Contracts.
An action link is an opaque URL, https://{site}/a/{linkId}, that a person can scan or open.
After they sign in or sign up, it resumes an action that a handler on this portal offers. The link
is an address, never a grant. Holding it entitles nobody to anything. The handler decides what
the signed-in recipient may do, afresh, every time.
Four records, four node types
| Record | Path | Node type | Who reads it |
|---|---|---|---|
| Definition — site, action (handler key, version, payload), card, shared prefill, validity, publisher, revocation | ActionLinks/{publicationKey} |
Essentials/ActionLinkDefinition |
the platform only |
| Presentation — the card, the site's origin, the validity window and the revocation stamp. Nothing else. | a/{linkId}, which is the URL |
Essentials/ActionLink |
every signed-in user, once the a partition is provisioned (below) |
| Handler page — where a rule sends the recipient | Essentials/ActionLinkHandlers/OpenPage |
Essentials/ActionLinkHandler |
every signed-in user (Essentials is pre-installed) |
| Continuation — the server-side handle that carries a browser back to the link through sign-in (below); stores the handle's fingerprint, never the handle | ActionLinks/continuation-{fingerprint} |
ActionContinuation |
the platform only |
The presentation never carries the publisher, the action, its payload or the prefill
(ActionLinkRules.Present, pinned by The presentation is sanitized).
Declaring and publishing
Actions.Link("launch-2026") // the publisher's own request id
.ForSite(site) // ActionLinkPublisher.ServingSite(hub): this portal's site
.Do(Actions.OpenPage("/Doc/Welcome")) // or a domain action: Coupons.Journey(…).BuildAction()
.Prefill(p => p.Language("en").Campaign("launch"))
.WithCard(c => c.Title("Welcome").Description("Start here."))
.ValidUntil(expiresAt) // required, at most 366 days
.Publish(hub, activityPath) // cold; emits one ActionLinkPublication
Constructing the declaration executes nothing. The action is data: a handler key, a version and
an opaque payload, never a delegate, a script or a method name. ActionLinkDraft.Refusal rejects an
incomplete or contradictory declaration before anything is written. That covers a missing request
id, a site that is not canonical, a missing action, title or expiry, an expiry in the past or more
than 366 days away, a prefill key outside the allowlist, and an open-page target that is not a page
on this site.
Publish follows the control-plane rule authorize as the caller, execute as the system:
- As the publisher. The signed-in person the request runs under is read once, at the call.
Then:
- the declaration must be complete;
- the link must be bound to the site this portal serves. The site comes from configuration:
Portal:BaseUrl(elsePublicBaseUrl) and the optionalActionLinks:SiteId. It never comes from a request header. Cross-site publication is not built. - the handler chain must resolve the action;
- the handler's own
Validatemust authorize the publisher. For a rule-bound handler that means the rule'sPublishPermissionon its handler node.
- As the platform. The
ActionLinksandapartition roots are created when they are absent. An existing root is adopted only when the platform created it and it is a Space. Then the definition is created atActionLinks/{publicationKey}. Its id is the publication's idempotency key (site, publisher, request id), so the create is the atomic arbiter: a retried request finds the link the first one created and returns it (Existing: true). It never mints a second link, and no query decides whether to create. After that the presentation is written. - As the publisher again. An Open link action (
ActivityActionReference) is recorded on the publishing activity, so its history keeps offering the link after the run (see the Activity page).
ActionLinkPublisher.Revoke(hub, linkId) lets only the publisher revoke a link. It stamps both
records. The link page then closes, including a page that is already open, and the handler page
refuses the link. Revoking twice converges.
Following a link
Not signed in. The
apartition grants nothing toAnonymous, so the portal's anonymous navigation gate sends the visitor to sign in withreturnUrl=/a/{linkId}. That return address is no longer what brings them back: a server-side continuation does (next section), so a sign-up, an e-mail verification or an onboarding that drops the return address still ends on the link.The link page (
a/{linkId},ActionLinkRecipient.For) is one template, bound live. It checks the following, in order:- The link serves. A revoked link wins over every other state, then an expired one, then one whose start is still ahead. Every unavailable state renders the same line, so the page never says which one applies. A one-shot timer closes the page at the expiry.
- The visitor is admitted, in the core contract's order: not signed in first, so an anonymous visitor learns nothing; then a link bound to another site.
- The handler's read-only
Preparenames the page Continue opens. Visiting, refreshing, previewing or returning from sign-in changes nothing.
The private definition is read as the platform. The visitor was admitted by reading the page and is never granted the definition.
The open-page handler page (
/Essentials/ActionLinkHandlers/OpenPage?link={linkId}) trusts nothing it was handed. It checks, as the viewer, that they may read the link's page. Then it re-decides everything as the platform: the link serves, it is bound to this site, the viewer is signed in, and the action isessentials.open-pageto a local page. Only then does it redirect. Like the link page it is a template emitted at once; its redirect is bound and navigates the moment the projection names the page.A fault is not a verdict. When a page cannot read the link right now, it shows a load-error line that asks the visitor to try again. It never shows the unavailable line for a fault, so an outage does not read as a revoked or expired link.
The portal's half of `/a/
The link page is in-mesh content; the HTTP request for it is the portal's. Three things are wired in
the portal host (src/Memex.Portal.Gui/ActionLinks/, MeshWeaver.Plugins#2060 slice 2):
| What | How | Where |
|---|---|---|
Headers. Every /a/{linkId} response — the page, the sign-in redirect, the onboarding redirect, a 429 — carries X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, Referrer-Policy: no-referrer and Cache-Control: no-store. |
Set on response start, before the identity middlewares run. | ActionLinkRoute.ApplyHeaders |
Throttling. Lookups of /a/{linkId} are limited per client (default 30 per minute; ActionLinks:Throttle:PermitLimit, ActionLinks:Throttle:WindowSeconds). An excess request gets 429 with Retry-After and one sentence in the visitor's language. |
ASP.NET Core's own rate limiter — a PartitionedRateLimiter as the global limiter, a fixed window per client address, the no-op partition for every other route. No hand-rolled counter, no static state: the limiter belongs to the host's container. It runs before identity, so a flood never mints a guest identity. |
ActionLinkThrottle |
| Continuation. The server-side record that carries a browser back to the link through sign-in. | Below. | ActionContinuation, ActionLinkRouteMiddleware |
The continuation
The design's ActionContinuation: an expiring opaque handle bound to the site and the
authentication transaction, then to the recipient.
- A visitor who is not signed in opens
/a/{linkId}. If the link EXISTS (a System listing ofa/{linkId}; a guessed id writes nothing), the portal records a continuation as the platform atActionLinks/continuation-{fingerprint}— in the private partition nobody is granted anything on — and sets the handle in an HttpOnly,SameSite=Laxcookie (mw-action-continuation). The record stores the SHA-256 fingerprint of the handle, never the handle. A refresh of the same link records nothing new. The page then sends the visitor to sign in, as before. - They sign in, sign up, verify their e-mail and onboard — in any order and over any number of pages. The continuation waits: the route resumes only a signed-in, onboarded person (it runs after the onboarding middleware) on a top-level page navigation, and never on sign-in, onboarding, authentication callbacks, the framework's transport, assets or APIs.
- The first page they land on spends the continuation and redirects them to
/a/{linkId}. Arriving on the link itself through the return address spends it the same way, without a redirect. Only the link the continuation names spends it: a signed-in person who opens a different link keeps theirs, so it is not lost. The redirect is rebuilt from the link id, never read from a stored field, so a resume can only land on a link page of this site.
Spending is atomic and binds one person. It is a fold on the record through
GetMeshNodeStream(path).Update(…), decided by the owning hub — the SetupLinkClaim pattern: two
racers with the same base, the second's stamp is refused as changed-since-base, and its fold re-runs
on the winner's state. The first signed-in person binds it. A changed account — someone else
signed in to the same browser — reads OtherAccount and is sent nowhere: nothing the first person
started is attached to the second. Expiry (30 minutes by default, ActionLinks:ContinuationMinutes)
bounds the hand-off, not the link: the person opens the link again, which is safe because the link
page re-decides everything. Every outcome clears the cookie.
Pinned by ActionLinkRouteTest (the step table, the exact link shape, the headers, the cookie, the
throttle's partitions, the 429 texts), ActionLinkRouteGuardsTest (the guards through a real HTTP
pipeline: an exhausted client gets 429 with Retry-After and the link headers, and never reaches
what is registered after the guards) and ActionContinuationTest (a real mesh: a guessed link
records nothing; the first person resumes; a second account gets nothing; two accounts racing bind
exactly one; an expired or another site's hand-off resumes nothing).
The live publication test
ActionLinkLiveTests runs in Essentials/ActionLink's Tests area, on the mesh the suite runs in:
it publishes a link, resolves it (the page serves, admission refuses an anonymous visitor and another
site), continues it (the open-page handler sends a signed-in recipient to the page and nowhere
else), revokes it (a stranger may not; the publisher may; the page and the handler then refuse it),
and removes both records on every path. A removal that fails FAILS the case, so a green run never
leaves a live synthetic link behind; when the case had already failed, both failures are reported. 🚨 It does not grant anyone the right to publish: it proves
the live handler chain's verdict on its synthetic publisher is the mesh's own permission verdict
(CheckPermissionOutcome for Update on the handler page), then drives the platform half of publication
the way Publish does after an authorization succeeds. The synthetic publisher is a signed-in person,
so it holds whatever Public holds: where the mesh grants that nothing the chain must REFUSE it (fail
closed); on a plugin gate, which grants Public root Admin, the chain must authorize it, and the case
logs that the refusal half was not measurable there. The authorization of a publisher who does
hold the right is therefore not exercised there.
The handler rule Essentials declares
Essentials/ActionLinkHandlers/OpenPageRule is an ActionHandlerRule in Essentials' own
namespace. That namespace is declared because installing the package declares it. The rule claims
essentials.open-page v1 for the OpenPage handler page, with publishPermission: "Update".
Publishing an open-page link therefore needs Update on Essentials/ActionLinkHandlers/OpenPage,
which is granted to nobody by default. A deployment that wants publishers grants that right to a
group: who may distribute links is the owner's decision. A domain package declares its own rule and
PublishPermission beside its own handler page, as the Store's coupon journey will.
Owner actions needed before anyone can use it
- Provision signed-in read on
a. Propose a governedaccess.grant-broadforPublic(every signed-in user, neverAnonymous) with theViewerrole on theapartition. Until that grant exists, a link page is readable by nobody but the platform, so recipients cannot open it. - Name the publishers. Grant Update on the handler page of each action that may be published
(for open-page:
Essentials/ActionLinkHandlers/OpenPage). - Configure the site.
Portal:BaseUrlmust be the portal's canonicalhttpsorigin.
Not built yet (remaining slices of #2060)
- Cross-site publication and handoff between registered sites.
- Redacted telemetry on the
/a/route (request logs and traces still carry the path). - A sweep of spent and expired continuation records; today they stay in the private partition.
- Standalone app launch: a home-screen web app has its own cookie jar on some platforms, so the continuation cookie does not reach it — part of MeshWeaver.Plugins#2062, verified on real devices.
- Throttling of the in-circuit navigation and the API reads of
a/{linkId}(the throttle covers the HTTP route; a signed-in client reading the node over the API or a Blazor navigation is bounded by the 122-bit id space, not by a limiter). - A live test of a publisher who holds the publish right (needs a governed grant on a disposable mesh, never on a shared one).
- The phone card, the server-generated QR and installation on a home screen are MeshWeaver.Plugins#2062, which needs real-device verification.