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

Samples

chat-room

A group chat — conversations, participants and messages, behind a login.

5 source files1 test file

The chat-room sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init chat-room
$ osy launch

The app

app.osy8 lines
// A group chat — conversations, participants and messages, behind a login.
// Originally the spike for M256: how much of a group chat already works with what shipped?
app ChatRoom {
  use Osyrin.Ui;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}
model/auth.osy77 lines
// The smallest real login, because this spike needs TWO DISTINCT PRINCIPALS and they must be real ones. An
// anonymous launch would make both viewers the same nobody, and the RLS half of the question — a non-participant is
// refused the room's messages — cannot be asked at all without two identities the server can tell apart.
using Osyrin.Ui;


[Role] enum AppRole { Authenticator, Member }

entity RoleGrant {
  User Grantee;
  [Required] AppRole Level;
  security { }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);

[Principal] entity User {
  [MaxLength(200), Unique] string Email;
  [MaxLength(200)] string PasswordHash;
  security {
    deny read PasswordHash when !IsAuthenticator;
    allow read, create, update when IsAuthenticator;
    allow read when IsAuthenticated;
  }
}

[AuthMethod]
string Signup(string email, string password) {
  var u = new User { Email = email, PasswordHash = Security.HashPassword(password) };
  return Security.IssueJwt(u.Id, u.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { Security.VerifyPassword(password); return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

app.AuthBootstrap = new AuthBootstrap {
  Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage,
};

[Page("/login")] [AllowAnonymous] [Render(CSR)]
component LoginPage() {
  string email = "you@room.test";
  string password = "demo-password";
  string problem = "";

  action DoSignup() {
    var ticket = Signup(email, password);
    if (ticket == "") { problem = "That email and password don't match anyone."; }
    else { Session.SignIn(ticket); }
  }

  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4,
        bg: Colors.Bg, color: Colors.OnBg) {
      Stack(gap: 4, w: "100%", maxW: "26rem") {
        Stack(gap: 1) {
          Text("The room", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold);
          Hint("Conversations, participants and messages — behind a login.");
        }
        Card("Sign in") {
          Stack(gap: 3) {
            Field("Email", value: email, placeholder: "you@example.com", type: "email");
            Field("Password", value: password, type: "password");
            if (problem != "") { Hint(problem); }
            Button("Create account", onPress: DoSignup, tone: Tone.Primary);
          }
        }
      }
    }
  }
}
model/chat.osy63 lines
// THE ROOM, AS THREE ENTITIES — and the security rule is the interesting line in the file.

// One room. `Topic` is what a person calls it; `StartedAt` orders rooms once there is more than one.
entity Conversation {
  [Required][MaxLength(120)] string Topic;
  [Required] DateTime StartedAt;
  security {
    allow read, create when IsAuthenticated;
  }
}

entity Participant {
  [Required] Conversation Conversation;
  [Required] User Person;
  [Required] DateTime JoinedAt;
  security {
    allow read where Participant.Any(p => p.Conversation == Conversation && p.Person == user);
    allow create where Person == user;
  }
}

entity Message {
  [Required] Conversation Conversation;
  [Required] User Author;
  [Required][MaxLength(2000)] string Body;
  [Required] DateTime SentAt;
  security {
    allow read where Participant.Any(p => p.Conversation == Conversation && p.Person == user);
    allow create where Author == user
                    && Participant.Any(p => p.Conversation == Conversation && p.Person == user);
  }
}

/// Make sure there is a room, and that the caller is in it. Returns the room's id, which is what the page's live
/// query filters on.
Guid JoinTheRoom() {
  var room = Conversation.OrderBy(c => c.StartedAt).FirstOrDefault();
  if (room == null) {
    room = new Conversation { Topic = "The room", StartedAt = DateTime.UtcNow };
  }

  var me = Session.CurrentUser;
  if (!Participant.Any(p => p.Conversation == room && p.Person == me)) {
    new Participant { Conversation = room, Person = me, JoinedAt = DateTime.UtcNow };
  }

  UnitOfWork.Commit();
  return room.Id;
}

/// Post a message to a room you are in.
void Say(Guid roomId, string body) {
  if (body.Trim() == "") { return; }

  var room = Conversation.Where(c => c.Id == roomId).FirstOrDefault();
  if (room == null) { throw new Exception("No such room."); }

  new Message {
    Conversation = room, Author = Session.CurrentUser, Body = body.Trim(), SentAt = DateTime.UtcNow,
  };
  UnitOfWork.Commit();
}
model/room.osy48 lines
// THE PAGE — and it is small on purpose. Everything interesting about this spike is in what the page does NOT say.

[Page("/")]
[Render(CSR)]
[Title("The room")]
component Room() {
  Guid roomId = Guid.Empty;

  string draft = "";

  live var messages = Message
    .Where(m => m.Conversation.Id == roomId)
    .Include(m => m.Author)
    .OrderBy(m => m.SentAt)
    .ToList();

  on mount { roomId = JoinTheRoom(); }

  action Send() {
    Say(roomId, draft);
    draft = "";
  }

  render {
    Stack(gap: 4, p: 6, maxW: "720px", bg: Colors.Bg, color: Colors.OnBg, minH: "100vh") {
      Text("The room", fontSize: FontSize.Hero, fontWeight: FontWeight.Medium);
      // The viewer's scalar fields ride the boot bag, so this is CLIENT state and costs no query.
      // Binding the whole entity (`var me = Session.CurrentUser;`) would have been a server read —
      // the row under this app's security — to render one string the browser already had.
      Text("Signed in as " + Session.CurrentUser.Email, fontSize: FontSize.Caption, color: Colors.TextMuted);

      Stack(gap: 2) {
        foreach (var m in messages) {
          Row(gap: 2) {
            Text(m.Author.Email + ":", fontWeight: FontWeight.Medium);
            Text(m.Body);
          }
        }
      }

      Row(gap: 2) {
        Input(value: draft, label: "Message", placeholder: "say something");
        Osyrin.Button("Send", onClick: Send);
      }
    }
  }
}
model/theme.osy14 lines
// chat-room — ITS OWN LOOK: a warm near-greyscale and a larger reading size, because a room of
// messages is read, not scanned. Deliberately NOT the shared demo theme; see `DemoSharedThemeTests`.

theme Room {
  Colors {
    Bg        = Modes.Of(light: "#FBFBFA", dark: "#1A1A19");
    OnBg      = Modes.Of(light: "#1E1E1C", dark: "#ECEAE6");
    TextMuted = Modes.Of(light: "#6C6A64", dark: "#A6A29A");
  }

  FontSize   { Caption = "12px"; Body = "15px"; Hero = "20px"; }
  FontWeight { Regular = 400; Medium = 500; }
}