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

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

<!-- id: security-invitation-signup · area: security · stability: preview · html: https://osysharp.com/reference/security/invitation-signup/ -->

## Summary        {#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 part | what carries it |
|---|---|
| they have no account, so they cannot sign in to accept | [`<Slot>.CallbackUrl()`](https://osysharp.com/reference/workflow/callback-url/) — a link that IS the permission |
| they have not answered, and somebody has to chase them | [`Remind`](https://osysharp.com/reference/workflow/remind/) on the milestone — the run chases itself |
| they never answer, and it must not sit open for ever | the milestone's breach arm — [`Unfinished { goto Expired; }`](https://osysharp.com/reference/workflow/milestone/) |
| an admin wants to see who is outstanding | [`Workflow.WorkByItem<Invitation>()`](https://osysharp.com/reference/workflow/work-by-item/) + 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      {#signature}
```osy syntax
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    {#description}

### The whole flow, compiled   {#the-flow}
`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.

```osy title="an invitation that chases itself" test app=security-invitation-signup
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);
}
```

### The link is the permission     {#the-link}
`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:

```text
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](#landing).

⚠ **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](https://osysharp.com/reference/workflow/callback-url/).

### The link goes to a PAGE, not to the callback endpoint     {#landing}
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:

```osy syntax
[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     {#credential}
⚠ **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:

```osy title="the token mints the account and its password together" syntax
// 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.
    // `token` is the last segment of the mailed AcceptLink; Redeem takes that or the whole URL ([Workflow.Redeem (answer a callback URL, as nobody)](https://osysharp.com/reference/testing/redeem-callback/)).
    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**:

```osy title="signup stays create-only, so there is nothing to claim" syntax
[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     {#two-doors}
`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?     {#desk}
[`Workflow.WorkByItem<Invitation>()`](https://osysharp.com/reference/workflow/work-by-item/) 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](https://osysharp.com/reference/workflow/audit/):

```osy syntax
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     {#resend}
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       {#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:

```bash
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:

```bash
curl -X POST 'http://wfsignupinvite.localhost:8156/api/workflow/callback/<token>' \
     -H 'Content-Type: application/json' -d '{"passwordHash": "<the hash the accept page computed>"}'
```

The invitation goes Active, an `Account` appears for that address **with the credential the POST carried** — never
without one, as `#credential` above insists — and the link is retired.
POST it a second time and it is `404` — the deposit burned it.

## See also       {#see-also}
- [Callback URLs — letting an outsider complete one slot](https://osysharp.com/reference/workflow/callback-url/) — the link's full security contract, and what it does *not* relax
- [Remind (milestone reminders)](https://osysharp.com/reference/workflow/remind/) — the chase, its cadence, and why a missed cadence is coalesced rather than replayed
- [Assigned / Finished (milestones)](https://osysharp.com/reference/workflow/milestone/) — `Within` and the breach arm that ends the run
- [Workflow.WorkByItem&lt;T&gt; (one row per item — the board read)](https://osysharp.com/reference/workflow/work-by-item/) — the per-item board the desk is built on
- [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/) — how `Signup`/`Login` are wired as the app's auth methods
- [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) — the grant table the accept arm writes into
