Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

Visitor

Visitor.Id — who this browser is, issued by the platform, for work begun before there is a user

`Visitor.Id` names the browser someone is using. The platform issues it on their first visit, proves it on every request with a sealed HttpOnly cookie, and answers the same value in a page, in a server function and inside a `security` rule. It gives work started before sign-up — a shop's basket, a saved filter — somewhere to belong, and with `where VisitId == Visitor.Id` a guest's rows are their own browser's and nobody else's.

stable4 examples compiled by CIuivisitoranonymousidentity

Summary#

Plenty of what a person does happens before they are anybody. They browse a catalogue, fill a basket, set a filter — and only then, if at all, do they sign up. Session.CurrentUser is null for all of it, which is correct and no help: that work still has to live somewhere, and still has to be there after a refresh.

Visitor.Id is the missing name. The platform issues it to a browser on its first request and proves it on every request after, so an app can key rows to the visit that created them, keep them the visitor's own, and hand them a real owner later.

[Route("/cart")]
[AllowAnonymous]
[Render(CSR)]
component CartPage() {
  string visitId = Visitor.Id;                 // who this browser is, signed in or not
  live var cart = Cart.Include(c => c.Lines).SingleOrDefault(c => c.VisitId == visitId);
  render {
    if (cart != null) { foreach (var l in cart.Lines) { Text(l.Product.Name); } }
  }
}

What this needs around it: a Cart entity carrying the visit's VisitId and a Lines collection, a CartLine, and a security block on each that says a guest's cart is its own visit's — guest rows (a visit's own rows). The example below declares all of it as one compiled app.

Signature#

Visitor.Id     // string — the id the platform issued this browser. Never empty in a page or a request.

Available in every component and every function, with no using, and — as Visitor.Id — inside a security rule.

Description#

How stable is Visitor.Id? — same browser, same value#

The same value on every page of the app, after a reload, in every tab, and after signing in or out. A different value in another browser, another profile, or a private window — and a new one if the visitor clears their site data, which is what "forget me" should mean.

It is not derived from anything about the person: not their address, not their device, not their behaviour. It is a random id the platform minted, and it says only "this is the same browser as before".

The platform issues it, and proves it#

The id never comes from the browser. On the first request the platform mints it and sets a cookie carrying it, sealed under the app's own key: the browser cannot read it from script (HttpOnly), another site cannot send it (SameSite=Lax), and on HTTPS a sibling app on the same domain cannot plant one (__Host-). Every later request is that visitor because its cookie opens under the key. A cookie that does not — edited, another app's, made up — is not a visitor at all: that request gets a fresh id, never the one it claimed.

The page is told the id (never the cookie) when it boots, so Visitor.Id in a component and Visitor.Id in the server function it calls are the same value.

Read it on the server — don't pass it#

A server function reads Visitor.Id itself; the visit it acts on is the caller's own, not one the caller names:

[Route("/products/{slug}")] [AllowAnonymous] [Render(CSR)]
component ProductPage(string slug) {
  var product = Product.SingleOrDefault(p => p.Name == slug);
  action Add(Product p) { AddToCart(p); }              // no "which visit" — the server knows
  render {
    if (product != null) { Pressable(onClick: () => Add(product)) { Text("Add to cart"); } }
  }
}

⚠ Never take the visit as a parameter. void AddToCart(string visitId, …) lets any caller name any visit — the parameter is somebody else's the moment a caller sets it. Read Visitor.Id in the body instead, and let the entity's rules say the same thing again (guest rows (a visit's own rows)).

Moving a visitor's rows to their account on sign-in#

The point of a visit-keyed row is that it becomes an owned one. When the visitor signs in, the app moves the rows it cares about from the visit to the user — the handover it exists for. The visit is still the caller's own, so the signed-in request reads it as the guest did:

void ClaimCart(User owner) {
  var visitId = Visitor.Id;
  var cart = Cart.SingleOrDefault(c => c.VisitId == visitId && c.Owner == null);
  if (cart != null) { cart.Owner = owner; }
}

Whether to claim, merge, or discard when the user already has rows of their own is the app's decision, and the platform has no opinion: it supplies the id and nothing else.

Where there is no visitor#

A workflow timer, a scheduled job or a background worker is not a request from a browser, so there Visitor.Id is null — and a rule comparing against it matches nothing. Work of that kind reads guest rows as the shop (a staff grant, or a workflow running as the system), never as a visitor.

In a test#

Each test is one fresh browser: a new visitor, and its Ui.* screen is handed the same one. To be a second browser, say so:

runas (new Visitor { Id = "guest-b" }) {
  Assert.Equal(0, CartSize());          // guest-b sees none of the first browser's cart
}

It changes only who the browser is — the signed-in principal, if any, stays — so runas (new Visitor { … }) { runas (Maja) { … } } is "Maja, signing in on that browser". Ui.* verbs work inside it: the test's browser becomes that visitor too.

Examples#

An anonymous basket — the whole shape, from an empty visit to a claimed cart, each guest reading only its own:

[Principal] entity User { string Email; }

entity Product {
  [Required, MaxLength(80)] string Name;
  decimal Price;
  security { allow read when IsAnonymous || IsAuthenticated; }   // a catalogue is public — that is what a shop is
}

entity Cart {
  [Required, MaxLength(64)] string VisitId;    // the visit this basket belongs to
  User Owner;                                  // null until someone signs in and claims it
  [ForeignKey(Cart)] CartLine[] Lines;
  // A guest's cart is its own visit's; a claimed one is its owner's.
  security { allow read, create, update when IsAnonymous || IsAuthenticated where Owner == null && VisitId == Visitor.Id || Owner == user; }
}

entity CartLine {
  [Required] Cart Cart;
  [Required] Product Product;
  int Quantity = 1;
  security {
    allow read, create, update when IsAnonymous || IsAuthenticated
      where Cart.Owner == null && Cart.VisitId == Visitor.Id || Cart.Owner == user;
  }
}

// The visit is the caller's own — read, never passed.
[AllowAnonymous]
void AddToCart(Product product) {
  var visitId = Visitor.Id;
  var cart = Cart.SingleOrDefault(c => c.VisitId == visitId);
  if (cart == null) { cart = new Cart { VisitId = visitId }; }
  var line = new CartLine { Cart = cart, Product = product };
  UnitOfWork.Commit();      // adding to a basket is a complete act — nothing else is going to commit it
}

[Route("/")]
[AllowAnonymous]
[Render(CSR)]
component Catalog() {
  live var products = Product.OrderBy(p => p.Name).ToList();

  action Add(Product p) { AddToCart(p); }

  render {
    Stack(gap: 3) {
      foreach (var p in products) {
        Row(gap: 2) {
          Text(p.Name);
          Button("Add to bag", onPress: () => Add(p));
        }
      }
    }
  }
}

See also#

Related

guest rows (a visit's own rows)

A row a guest makes before signing in — a basket, a saved choice, what they typed at the checkout — belongs to their…

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

page authorization (policies)

A `policy` names a reusable authorization predicate over the current user — e.g. `policy Admins => UserRole.Any(r =>…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…