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:

  1. 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 (else PublicBaseUrl) and the optional ActionLinks: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 Validate must authorize the publisher. For a rule-bound handler that means the rule's PublishPermission on its handler node.
  2. As the platform. The ActionLinks and a partition 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 at ActionLinks/{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.
  3. 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.

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.

  1. A visitor who is not signed in opens /a/{linkId}. If the link EXISTS (a System listing of a/{linkId}; a guessed id writes nothing), the portal records a continuation as the platform at ActionLinks/continuation-{fingerprint} — in the private partition nobody is granted anything on — and sets the handle in an HttpOnly, SameSite=Lax cookie (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.
  2. 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.
  3. 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

Not built yet (remaining slices of #2060)