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() — a link that IS the permission |
| they have not answered, and somebody has to chase them | 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; } |
| an admin wants to see who is outstanding | Workflow.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);
}The link is the permission#
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 launchThen 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#
- Callback URLs — letting an outsider complete one slot — the link's full security contract, and what it does not relax
- Remind (milestone reminders) — the chase, its cadence, and why a missed cadence is coalesced rather than replayed
- Assigned / Finished (milestones) —
Withinand the breach arm that ends the run - Workflow.WorkByItem<T> (one row per item — the board read) — the per-item board the desk is built on
- auth bootstrap (login, before anyone is signed in) — how
Signup/Loginare wired as the app's auth methods - role grants (and the first admin) — the grant table the accept arm writes into