Osy#betaRun it on Osyrin: osyrin.com

Samples

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

The chat-room sample, running: The room, after three messages
The room, after three messages

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");
      }
    }
  }
}