Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Security

signup by invitation (invite, accept link, chase, expire)

subscribe Accept(string passwordHash) as Acceptance { Finished { Within = …; Unfinished { goto Expired; } } }

How an app lets somebody INVITE a person who has no account yet. The invitation is a workflow: it mints a tokenised accept link the invitee can answer with no login, chases them on a cadence if they do not, and expires on its own if they never do. Accepting mints the account AND its password in one step, so it is never claimable by anyone else. Nothing here is a cron job, a sweep, or a `LastNudgedAt` column.

preview1 example compiled by CIsecurityworkflowauthoringonboarding

Summary#

Almost every application needs this and it is always the same shape: somebody invites an address, the person at that address gets a link, and clicking it makes them a user. The hard parts are not the invite — they are everything around it. What if they never answer? What if they answer twice? What if they have no account to answer with?

In Osy# the invitation is a workflow over the invitation row, and that answers all three:

the awkward partwhat carries it
they have no account, so they cannot sign in to accept<Slot>.CallbackUrl() — a link that IS the permission
they have not answered, and somebody has to chase themRemind on the milestone — the run chases itself
they never answer, and it must not sit open for everthe milestone's breach arm — Unfinished { goto Expired; }
an admin wants to see who is outstandingWorkflow.WorkByItem<Invitation>() + the audit trail

No cron, no sweep, and no bookkeeping columns. The instinct is to grow LastNudgedAt, NudgeCount and ExpiresAt on the invitation and a job to maintain them. The engine already holds the clock and already records every reminder it fired, so the app reads them instead of keeping its own copy that can drift.

Signature#

state Pending {
  subscribe Accept(string passwordHash) as Acceptance {
    Finished {
      Within = <TimeSpan>;                       // how long they have
      Remind Chase(After = …, ThenEvery = …) { } // how they are chased before that
      Unfinished { goto Expired; }               // what happens if they never answer
    }
  }
  enter { this.Item.AcceptLink = Acceptance.CallbackUrl(); }   // the link the email points a page at
  on Acceptance(string passwordHash) { /* mint the account AND its credential */ goto Active; }
}

Description#

The whole flow, compiled#

Invitation is an ordinary entity; the workflow tracks its status. Autostart means creating the row starts the run, so "invite this address" is one new Invitation { … } and nothing else.

enum InviteStatus { Pending, Active, Revoked, Expired }

[Role] enum AppRole { Authenticator, Member, Admin }

[Principal]
entity Account {
  [Required, MaxLength(100)] string Name;
  [Required, MaxLength(200)] string Email;
  // Nullable because the SEEDED accounts in a fixture may have none. An account minted by ACCEPTING always has its
  // hash from the moment it exists — see `on Acceptance` below, and the section on why that matters.
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read when IsAuthenticated;
    allow read, create, update when IsAuthenticator;
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity RoleGrant {
  [Required] Account Grantee;
  [Required] AppRole Level = AppRole.Member;
  security { allow read when IsAuthenticated; allow create when IsAuthenticator; }
}

entity Invitation {
  [Required, MaxLength(200)] string Email;
  InviteStatus Status;
  // Where the minted link is kept so the app can mail it. `CallbackUrl()` returns the plaintext ONCE — only its
  // hash is stored — so the body that mints it is the only place it can be put anywhere.
  [MaxLength(500)] string? AcceptLink;
  security { allow read, create, update when IsAuthenticated; }
}

// The auth flow runs as the EPHEMERAL Authenticator — no user yet — so it needs its own grant to reach the
// credential rows it was called to check.
policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
policy IsAdmin         => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Admin);

workflow Onboarding {
  Tracks    = Invitation.Status;
  Autostart = true;
  Initial   = Pending;

  // Only the person it was addressed to may accept from INSIDE the app — an identity comparison, per row.
  [Authorize(u => u.Email == this.Item.Email)]
  event Accept(string passwordHash);

  // Revoking is an office, so it is a grant lookup: per person, the same answer on every invitation.
  [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Admin))]
  event Revoke();

  on Revoke { goto Revoked; }

  state Pending {
    subscribe Accept() as Acceptance {
      Finished {
        Within = TimeSpan.FromDays(7);
        Remind Chase(After = TimeSpan.FromDays(3), ThenEvery = TimeSpan.FromDays(2)) {
          Nudge(this.Item);
        }
        Unfinished { goto Expired; }
      }
    }

    enter { this.Item.AcceptLink = Acceptance.CallbackUrl(); }

    on Acceptance(string passwordHash) {
      // The deposit may have arrived with NOBODY signed in, so accepting is what mints the account — WITH its
      // credential, in one step, so there is never a moment when it can be claimed by somebody else.
      var existing = Account.Where(a => a.Email == this.Item.Email).FirstOrDefault();
      if (existing == null) {
        var minted = new Account { Name = this.Item.Email, Email = this.Item.Email, PasswordHash = passwordHash };
        new RoleGrant { Grantee = minted, Level = AppRole.Member };
      }
      this.Item.AcceptLink = null;     // the deposit burned the token; do not advertise a dead link
      goto Active;
    }
  }

  terminal success Active  { }
  terminal cancel  Revoked { Message = "invitation revoked"; }
  terminal error   Expired { Message = "invitation expired"; }
}

void Nudge(Invitation i) {
  Log.Information("invitation reminder — {Email} has not accepted yet", i.Email);
}

Acceptance.CallbackUrl() mints an absolute URL for that one slot on that one run. The invitee answers it by POSTing to it — with no account, no session and no sign-in:

POST https://myapp.example.com/api/workflow/callback/LBuW9YC_9YEaXtku…

The body is the event's parameters as a JSON object, by name — here {"passwordHash": "…"}. An event with no parameters is answered by posting nothing at all.

The invitee never does this by hand. A callback URL is answered by a POST and an email link is a GET, so the address in the email is a page of yours that carries the token and POSTs on submit — see below.

The event's [Authorize] does not govern this door, and cannot. A predicate takes a principal and a callback deposit has none — that is the whole point of the feature. What stands in its place is the token: 256 bits, stored only as a hash, single-use, scoped to one slot on one run, and dead the moment the slot closes. So whoever can read the invitee's mail can accept the invitation — which is exactly the authority a real invite link carries, and it is worth knowing that you are choosing it. The full contract is in Callback URLs — letting an outsider complete one slot.

The link goes to a PAGE, not to the callback endpoint#

A callback URL is answered by a POST, on purpose — a link that acted on being fetched would be spent by the first mail scanner or link preview that touched it. An email link is a GET. So the address in the email is a page of your own, which carries the token and does the POST when the invitee submits:

[Page("/accept/{token}")] [AllowAnonymous]      // whoever opens it has no account and cannot sign in
component AcceptInvite(string token) { … collect a password, then call the function below … }

Accepting mints the account WITH its password, in one step#

This is the part that is easy to get wrong, and getting it wrong is an account takeover. The tempting shape is: the arm mints an Account with no PasswordHash, and the invitee sets one later at the signup form, which "claims" the credential-less row. Do not build that. A row with no password is a row anybody who knows the address can claim — and an invitation is precisely where an attacker knows the address. The token proved that this person controls this mailbox, and the claim throws that proof away.

Mint the account and its credential together, authorized by the token, so the window never opens:

// the landing page's one call
string AcceptInvitation(string token, string password) {
  try {
    // Hash FIRST, then answer the link: an event argument is not a safe place for a plaintext, because a body that
    // parks (a retry, an await) persists its args to resume with.
    Workflow.Redeem(token, "{\"passwordHash\": \"" + Security.HashPassword(password) + "\"}");
    return "";
  }
  catch (NotFoundException e) { return "This link is not valid, or it has already been used."; }
  catch (ConflictException e) { return "This invitation is no longer open."; }
}

The token is the only authority here, and the address is never taken from the request — the arm reads it off this.Item, the invitation the token addresses. So a caller holding a valid link cannot aim it at somebody else's invitation, and one holding no link can do nothing at all. That is why this needs no [Authorize] and no signed-in user: there is nobody to authorize.

And Signup stays create-only:

[AuthMethod]
string Signup(string email, string password) {
  if (Account.Any(a => a.Email == email)) { return ""; }   // taken — sign in, or use your invitation link
  var a = new Account { Name = email, Email = email, PasswordHash = Security.HashPassword(password) };
  var grant = new RoleGrant { Grantee = a, Level = AppRole.Member };
  return Security.IssueJwt(a.Id, a.Email);
}

There is nothing here to claim, because a credential-less account is not a state the app ever has.

Two doors into one slot#

Acceptance is satisfied either by the emailed link (anonymous, over its token) or by an Accept button inside the app (a signed-in invitee, over the [Authorize]). Whichever arrives first satisfies it; the second finds it closed and is told so. You do not choose between them — one slot serves both.

How do I watch the pending invitations?#

Workflow.WorkByItem<Invitation>() gives one row per invitation with a live run: how long is left, and what governs. The chase count and the breach come off the run's audit trail:

foreach (var a in Onboarding.For(i).Audit) {
  if (a.Kind == AuditKind.Reminded) { nudges = nudges + 1; }
  if (a.Kind == AuditKind.Breached) { everBreached = true; }
}

Read the breach off the TRAIL, not off the work row, when the breach ENDS the run. WorkByItem is one row per live run — and an invitation whose deadline lapsed goto Expired, so its row and its EverBreached disappear in the same sweep that made the answer true. (WorkByItem.EverBreached is for the other shape: a promise missed on a run that stays open, like a support ticket still owed an answer.) The audit trail has no such horizon.

Resending, and revoking#

Calling CallbackUrl() again for the same slot issues a new link and retires the old one — which is what a resend must do, so that correcting a typo'd address does not leave the first address able to answer. Revoking is an ordinary workflow-level route, so it works from any non-terminal state.

Examples#

The complete, running application this page is drawn from is demo/wf-signup-invite — model, tests, a login page and an invite desk that shows each invitation's countdown, its chase count and its live accept link. Run it:

cd demo/wf-signup-invite
osy test
osy user add ada@corp.test --role Admin --password demo1234 --set Name=Ada
osy import --as ada@corp.test --password demo1234
osy launch

Then accept one the way an invitee with no account would — by POSTing to the link the desk is showing:

curl -X POST 'http://wfsignupinvite.localhost:8156/api/workflow/callback/<token>'

The invitation goes Active, an Account appears for that address with no password yet, and the link is retired. POST it a second time and it is 404 — the deposit burned it.

See also#

Related

Callback URLs — letting an outsider complete one slot

Mint a single-use link that completes exactly one waiting slot, for a third party who has no account and cannot sign…

Remind (milestone reminders)

A reminder scheduled off a milestone. Its SCHEDULE is config in the header parens — `After` is the first fire (once, at…

Assigned / Finished (milestones)

A milestone puts an SLA on a slot's progress — Assigned (someone must PICK IT UP within Within) and Finished (it must…

Workflow.WorkByItem&lt;T&gt; (one row per item — the board read)

One row per tracked ENTITY, where `Workflow.Work<T>()` is one per SLOT. A board, a queue and a "my work" screen are all…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

role grants (and the first admin)

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property…

[Authorize] (event)

Gates WHO may raise a workflow event. `[Authorize]` on an event is a `principal => bool` predicate over the acting…