chat-room
A group chat on Osysharp.Conversations — one public room, behind a login. The smallest app that invokes the kit: it says who its staff are and what they may do (model/room/chat.osy), and the room page is the kit's own transcript and composer. Originally the spike for M256: how much of a group chat already works with what shipped?
5 source files1 test file

Get it
$ osy init chat-room $ osy launch
The app
app.osy13 lines
// A group chat on Osysharp.Conversations — one public room, behind a login. The smallest app that invokes the kit: it // says who its staff are and what they may do (model/room/chat.osy), and the room page is the kit's own transcript and // composer. Originally the spike for M256: how much of a group chat already works with what shipped? app ChatRoom { use Osysharp.Ui; use Osysharp.Identity@0; use Osysharp.Conversations@0; use Osysharp.Presence@0; model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/look/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; } }
model/people/auth.osy80 lines
// The smallest real login, because a room needs TWO DISTINCT PRINCIPALS and they must be real ones — the question "is // a non-member refused the room's messages" cannot be asked without two identities the server can tell apart. An // account is Osysharp.Identity's `Principal`; this app adds the password. using Osysharp.Identity; [Role] enum AppRole { Authenticator, Member } entity User : Principal { [MaxLength(200)] string PasswordHash; security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; deny read PasswordHash when !IsAuthenticator; } } [AuthMethod] string Signup(string email, string password) { if (password.Length < 8) { return ""; } // hashing is not a length check: "" and "a" hash perfectly well var u = new User { Email = email, DisplayName = 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 ""; } // Audit redaction is EXPLICIT. The entity-change trail records every changed property's old AND new value verbatim // into a durable, exportable log, and the `deny read` on the credential does not cover it — that governs READERS, // and the audit writer is not one. Named here, the trail records THAT the field changed and never its value. app.Audit = new AuditConfig { Redact = new AuditRedaction { Properties = [User.PasswordHash] }, }; // Which fields hold the login and its hash — what `osy user add` (and so a screenshot run's seed) signs people up with. app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, }; [Title("Sign in")] [Route("/login")] [AllowAnonymous] [Render(CSR)] component LoginPage() { string email = "[email protected]"; string password = "demo-password"; string problem = ""; action DoSignup() { var ticket = Signup(email, password); if (ticket == "") { problem = "Pick a password of at least 8 characters."; } 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("A group chat on Osysharp.Conversations — behind a login."); } Card("Sign in") { Stack(gap: 3) { Field("Email", value: email, placeholder: "[email protected]", type: "email"); Field("Password", value: password, type: "password"); if (problem != "") { Hint(problem); } Button("Create account", onPress: DoSignup, tone: Tone.Primary); } } } } } }
model/room/chat.osy68 lines
// THE ROOM, ON THE KIT — the conversation, its seats and its messages are Osysharp.Conversations'. What this app says // is the two things the kit never knows: who its staff are, and what they may do. using Osysharp.Conversations; /// Everybody signed in is on the team. A team chat has no customers — every seat is a member's. policy IsConversationStaff => IsAuthenticated; // Who sits on a conversation (and any record) with the desk — the Presence kit's question. policy CollaboratesOnRecords => IsConversationStaff; /// Seated in this conversation: the membership a message's readers are read from. policy IsSeatedIn(Guid conversation) => Osysharp.Conversations.Participant.Any(p => p.Conversation.Id == conversation && p.Member == user); // ⚑ THE ROOM IS PUBLIC — anybody signed in may see it and take a seat in it — so the conversation itself is readable // by all of them. What it SAYS is not: a message is readable, and writable, only by whoever holds a seat. partial entity Conversation { security { allow read, create, update when IsAuthenticated; } } // ⛔ `Member == user` IS NOT A MEMBERSHIP GATE, and it is only correct here because the room is PUBLIC — seating // yourself in one gives you nothing you could not already have. Read it as "you may take your own seat, and nobody // else's". In an app whose conversations are private, a conversation is addressed by a ROW, so seating yourself in // one IS joining it (`demo/ember` measured that: an outsider walked into a two-person DM). There the safe predicate // is "a conversation you are ALREADY in". Pinned by tests/room.test.osy. partial entity Participant { security { allow read when IsAuthenticated where IsSeatedIn(Conversation.Id); allow create, update when IsAuthenticated where Member == user; } } partial entity ConversationEntry { security { allow read when IsAuthenticated where IsSeatedIn(Conversation.Id); } } partial entity Message { security { allow read, create when IsAuthenticated where IsSeatedIn(Conversation.Id); } } /// Make sure there is a room, and that the caller is in it. Returns the room's id, which is what the page shows. Guid JoinTheRoom() { var room = Conversation.OrderBy(c => c.StartedAt).FirstOrDefault(); if (room == null) { room = new Conversation { Subject = "The room", StartedAt = DateTime.UtcNow }; } Seat(room, Session.CurrentUser); 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.FirstOrDefault(c => c.Id == roomId); if (room == null) { throw new Exception("No such room."); } Post(room, body); UnitOfWork.Commit(); } // The kit's bookkeeping for channels, merges and erasure — a team room has no channel but the web and no customers, // so nothing here reaches them, and they stay denied. [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity MessageDelivery { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity ConversationThreadKey { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity PartyErasure { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity PartyMerge { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity ConversationSplit { }
model/room/room.osy37 lines
// THE PAGE — the kit's transcript and composer, around one call that seats you in the room. using Osysharp.Conversations; [Route("/")] [Render(CSR)] [Title("The room")] component Room() { Guid roomId = Guid.Empty; on mount { roomId = JoinTheRoom(); } // What the page looks like while the room is still on the way — its SHAPE, so a slow answer never reads as a hang. skeleton { Stack(gap: 4, p: 6, maxW: "720px", minH: "100vh") { Skeleton(shape: SkeletonShape.HalfLine); Skeleton(); Skeleton(shape: SkeletonShape.Control); } } render { Stack(gap: 4, p: 6, maxW: "720px", mx: "auto", 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. Text("Signed in as " + Session.CurrentUser.Email, fontSize: FontSize.Caption, color: Colors.TextMuted); if (roomId != Guid.Empty) { // `desk: true` — every seat here is a member's, so every message is "ours"; nobody is kept from a note, so // the composer offers none. ConversationTranscript(roomId, desk: true); ConversationPresenceChip(roomId, desk: true); ConversationComposer(roomId, desk: true, notes: false, label: "Message", placeholder: "say something"); } } } }