doc-vault
doc-vault — the ENTITY INHERITANCE demo. One table, four kinds of document, three levels deep.
7 source files2 test files

Get it
$ osy init doc-vault $ osy launch
The app
app.osy7 lines
// doc-vault — the ENTITY INHERITANCE demo. One table, four kinds of document, three levels deep. app DocVault { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/identity.osy54 lines
// The identity tier, self-contained — a [Principal] account, a [Role] enum, and a RoleGrant table recognised by its // SHAPE. Declared here rather than shared so this demo compiles on its own, the way a downloader's copy would. [Role] enum AppRole { Authenticator, Member, Legal, Finance } [Principal] entity Account { [Required, MaxLength(200)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string PasswordHash; security { allow read when IsAuthenticated; allow read, create 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; allow create, update, delete when IsLegal; } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsLegal => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Legal); policy IsFinance => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Finance); [AuthMethod] string Signup(string email, string password) { 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); } [AuthMethod] string Login(string email, string password) { var a = Account.Where(x => x.Email == email).FirstOrDefault(); if (a == null) { Security.VerifyPassword(password); return ""; } if (Security.VerifyPassword(password, a.PasswordHash)) { return Security.IssueJwt(a.Id, a.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, };
model/seed.osy45 lines
// Seeding, split by AUTHORITY — and the split is not incidental, it is the app's subject showing up in its own // setup. The first version of this file was one `SeedVault()`, and running it as Legal died on the Invoice with // "Create of 'Invoice' denied … the caller does not satisfy the guard" — correctly, and it rolled the whole thing // back. Three verbs, run as three people: // Anyone signed in: Document lets any authenticated caller create, and so does Memo. void SeedShared() { if (Document.Where(d => d.Reference == "DOC-1").ToList().Count == 0) { new Document { Reference = "DOC-1", Title = "Vault index", State = DocState.Filed }; } if (Memo.Where(m => m.Reference == "MEMO-1").ToList().Count == 0) { var m = new Memo { Reference = "MEMO-1", Title = "Office closed on the 24th", State = DocState.Filed }; new Note { Document = m, Body = "circulated to all staff" }; } if (Memo.Where(m => m.Reference == "MEMO-2").ToList().Count == 0) { new Memo { Reference = "MEMO-2", Title = "Fire drill, Thursday 09:00", State = DocState.Draft }; } } void SeedContracts() { if (Contract.Where(c => c.Reference == "CON-1").ToList().Count == 0) { var c = new Contract { Reference = "CON-1", Title = "Server supply agreement", Counterparty = "Acme Hardware", Value = 48000m, State = DocState.InReview }; new Note { Document = c, Body = "legal review in progress" }; } if (SignedContract.Where(c => c.Reference == "CON-2").ToList().Count == 0) { var sc = new SignedContract { Reference = "CON-2", Title = "Support retainer 2026", Counterparty = "Zenith Services", Value = 12500m, State = DocState.Filed, Signatory = "Ada Lovelace", SignedAt = DateTime.UtcNow }; new Note { Document = sc, Body = "countersigned copy filed" }; } } void SeedInvoices() { if (Invoice.Where(i => i.Reference == "INV-1").ToList().Count == 0) { var i = new Invoice { Reference = "INV-1", Title = "March hosting", Counterparty = "Zenith Services", Amount = 940m, State = DocState.Filed }; new Note { Document = i, Body = "paid 2026-04-02" }; } if (Invoice.Where(i => i.Reference == "INV-2").ToList().Count == 0) { new Invoice { Reference = "INV-2", Title = "April hosting", Counterparty = "Zenith Services", Amount = 940m, State = DocState.InReview }; } }
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a // byte-identical `model/theme.osy`, and that test is what keeps them identical. // // ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point. // The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`, // `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own // `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment // one of them reached for a kit control, that control painted itself indigo next to the demo's // blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that // existed only in the theme file. // // So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name // the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it // does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything. // // Excluded by design — these five own their look and must NOT adopt this file: arcade, ember, // motion, gestures (each demonstrates a visual world) and Apps/recall-osy. theme Demo { Colors { // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a // considered app rather than an unstyled one, and the two should be distinguishable on sight. // It is the one saturated colour on the page; everything else is warm neutral, which is what // keeps "distinct" from turning into "loud". Primary = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6")); OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F"); Bg = Modes.Of(light: "#FAF9F7", dark: "#101317"); // the page — a touch warm, so cards read as lifted Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22"); // a card, a row, a lane Muted = Modes.Of(light: "#EFEEEA", dark: "#1E242B"); // an inline notice, a disabled field Border = Modes.Of(light: "#E4E2DC", dark: "#28303A"); OnBg = Modes.Of(light: "#14181D", dark: "#E7EBF0"); OnSurface = Modes.Of(light: "#14181D", dark: "#E7EBF0"); TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3"); // labels, timestamps, captions TextMuted = Modes.Of(light: "#7C848E", dark: "#7E8894"); // the quietest text on the page Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A")); Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343")); Danger = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A")); } // Names the kit does not have, so these are extensions rather than second spellings. Radius { Control = "8px"; Card = "12px"; } // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans` // shadows the kit's own token — so every kit control picks it up with no page edit at all. // Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font // add`), because a webfont a page merely NAMES is a webfont that silently falls back. // Only the faces the app SHIPS are named here — Mono is left at the kit default because no // demo renders code, and naming an unshipped family is a silent fallback. // Geist for the UI: a neutral grotesque with real character at small sizes, which is where a // dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page // title. The pairing is the whole look — one voice for reading, one for announcing. Font { Sans = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif"; Serif = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif"; } FontSize { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; } // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. }
model/vault.osy53 lines
// THE VAULT — one table, four kinds of document, three levels deep. enum DocState { Draft, InReview, Filed } entity Document { [Required, Unique, MaxLength(40)] string Reference; [Required, MaxLength(200)] string Title; DocState State = DocState.Draft; [ForeignKey(Document)] Note[] Notes; invariant Title.Length > 2 message "a document needs a real title"; semantic => $"{Reference} — {Title}"; security { allow create, read, update when IsAuthenticated; } } entity Note { [Required] Document Document; [Required, MaxLength(400)] string Body; security { allow create, read when IsAuthenticated; } } entity Contract : Document { [Required, MaxLength(120)] string Counterparty; [Required] decimal Value; invariant Value > 0 message "a contract has a value"; security { allow create, read, update when IsLegal; } } entity Invoice : Document { [Required, MaxLength(120)] string Counterparty; [Required] decimal Amount; security { allow create, read when IsFinance; } } entity Memo : Document { security { allow create, read when IsAuthenticated; } } entity SignedContract : Contract { [Required, MaxLength(120)] string Signatory; [Required] DateTime SignedAt; semantic => $"{Reference} — {Title}, signed by {Signatory}"; security { allow create, read when IsLegal; } }
model/pages/login.osy53 lines
// Sign in. So the first thing the demo needs is a way to be somebody else. using Osyrin.Ui; [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Document vault — sign in")] component LoginPage() { string email = "ada@vault.test"; string password = "demo1234"; string problem = ""; action SignIn() { var ticket = Login(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: "440px") { Stack(gap: 1) { Text("Document vault", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em"); Text("Four kinds of document, one table, three levels deep.", fontSize: FontSize.Body, color: Colors.TextSecondary); } Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Input(value: email, label: "Email", placeholder: "you@vault.test"); Input(value: password, label: "Password", type: "password", placeholder: "password"); if (problem != "") { Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) { Text(problem, fontSize: FontSize.Caption, color: Colors.Danger); } } Button("Sign in", onPress: SignIn, tone: Tone.Primary); } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 2) { Text("Sign in as each of these and compare — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Text("ada@vault.test — Legal: sees contracts, signed and unsigned", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("finn@vault.test — Finance: sees invoices, and no contract at all", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("mo@vault.test — neither: sees only memos", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } }
model/pages/vault.osy209 lines
// THE VAULT PAGE — the hierarchy, rendered. using Osyrin.Ui; [Page("/")] [Render(CSR)] [Title("Document vault")] component VaultPage() { live var plain = Document.Include(d => d.Notes).ToList(); live var contracts = Contract.Include(c => c.Notes).ToList(); live var signed = SignedContract.Include(c => c.Notes).ToList(); live var invoices = Invoice.Include(i => i.Notes).ToList(); live var memos = Memo.Include(m => m.Notes).ToList(); action SignOut() { Session.SignOut(); } int contractsSeen = -1; string signedBy = ""; action CountByType() { contractsSeen = 0; signedBy = ""; foreach (var d in plain) { if (d is Contract) { contractsSeen = contractsSeen + 1; } if (d is SignedContract sc) { signedBy = signedBy + sc.Signatory; } } } action AddShared() { SeedShared(); } action AddContracts() { SeedContracts(); } action AddInvoices() { SeedInvoices(); } render { Stack(gap: 5, p: 6, bg: Colors.Bg, color: Colors.OnBg, minH: "100vh") { Stack(gap: 1) { Text("Document vault", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em"); Text("Four kinds of document. One table. Three levels deep. Every section below is a separate read of a " + "separate TYPE — and which of them has anything in it is decided by that type's own security block, " + "not by this page.", fontSize: FontSize.Body, color: Colors.TextSecondary, maxW: "70ch"); Row(gap: 3, align: Align.Center) { Text("Signed in as " + Session.CurrentUser.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); Button("Sign out", onPress: SignOut); } Row(gap: 2, align: Align.Center) { Button("Add memos", onPress: AddShared); Button("Add contracts (Legal)", onPress: AddContracts); Button("Add invoices (Finance)", onPress: AddInvoices); Button("Count by type", onPress: CountByType); } if (contractsSeen >= 0) { Text("By type test, in an action: " + contractsSeen + " contract(s), signed by [" + signedBy + "]", fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (Failure.Any) { Box(bg: Colors.Muted, rounded: Radius.Control, p: 3, borderW: 1, border: Colors.Danger) { Row(justify: Justify.SpaceBetween, align: Align.Start, gap: 3) { Stack(gap: 1) { Text("That was refused", fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold, color: Colors.Danger); Text(Failure.Message, fontSize: FontSize.Caption, color: Colors.TextSecondary, maxW: "90ch"); } Button("Dismiss", onPress: Failure.Dismiss); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border, label: "Every document") { Stack(gap: 3) { SectionHead(heading: "Document — the root, read polymorphically", count: plain.Count + " of every kind", note: "A base read returns every kind BELOW it, exactly as C# and EF mean it — the contracts, " + "the invoices and the memos are IN this count, not merely in the same table. And each " + "row that comes back is governed by its OWN rules, which is why this one number is " + "different for Ada, for Finn and for an ordinary member."); Stack(gap: 2) { foreach (var d in plain) { Card(d.Reference, d.Title, d.State.Label, d.Notes.Count + " note(s)"); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border, label: "Contracts") { Stack(gap: 3) { SectionHead(heading: "Contract : Document — Legal only", count: contracts.Count + " row(s)", note: "Its own two fields on top of everything Document declares. Empty unless you are signed in as Legal, " + "and nothing on this page checks that."); Stack(gap: 2) { foreach (var c in contracts) { Card(c.Reference, c.Title, c.Counterparty + " · " + c.Value, c.Notes.Count + " note(s)"); } if (contracts.Count == 0) { NothingHere("No contracts you may read. Sign in as Legal to see these."); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border, label: "Signed contracts") { Stack(gap: 3) { SectionHead(heading: "SignedContract : Contract : Document — three levels", count: signed.Count + " row(s)", note: "Reference and Title come from Document, Counterparty and Value from Contract, Signatory from itself. " + "The page reads all five off one object."); Stack(gap: 2) { foreach (var sc in signed) { Card(sc.Reference, sc.Title, sc.Counterparty + " · " + sc.Value + " · signed by " + sc.Signatory, sc.Notes.Count + " note(s)"); } if (signed.Count == 0) { NothingHere("No signed contracts you may read. Sign in as Legal to see these."); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { SectionHead(heading: "Invoice : Document — Finance only", count: invoices.Count + " row(s)", note: "A sibling of Contract. Its Counterparty is literally the same physical column as Contract's — one " + "column, two types, two values, because they are different rows."); Stack(gap: 2) { foreach (var i in invoices) { Card(i.Reference, i.Title, i.Counterparty + " · " + i.Amount, i.Notes.Count + " note(s)"); } if (invoices.Count == 0) { NothingHere("No invoices you may read. Sign in as Finance to see these."); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { SectionHead(heading: "Memo : Document — adds nothing", count: memos.Count + " row(s)", note: "A distinct TYPE with no members of its own — still its own reads and its own rules."); Stack(gap: 2) { foreach (var m in memos) { Card(m.Reference, m.Title, m.State.Label, m.Notes.Count + " note(s)"); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { SectionHead(heading: "One list, every kind — the row decides how it draws", count: plain.Count + " walked", note: "The SAME rows as the first section, in one loop with no type in the query. What each " + "line shows is decided by what the row turned out to be — and a kind you may not read " + "is not here to be asked about, because security already answered."); Stack(gap: 2) { foreach (var d in plain) { if (d is SignedContract sc) { Card(d.Reference, d.Title, "SignedContract · signed by " + sc.Signatory, d.Notes.Count + " note(s)"); } else if (d is Contract con) { Card(d.Reference, d.Title, "Contract · " + con.Counterparty, d.Notes.Count + " note(s)"); } else if (d is Invoice inv) { Card(d.Reference, d.Title, "Invoice · " + inv.Counterparty, d.Notes.Count + " note(s)"); } else if (d is Memo) { Card(d.Reference, d.Title, "Memo · adds nothing of its own", d.Notes.Count + " note(s)"); } else { Card(d.Reference, d.Title, "Document · the root itself", d.Notes.Count + " note(s)"); } } } } } } } } [Composable] component NothingHere(string what) { render { Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) { Text(what, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } [Composable] component SectionHead(string heading, string count, string note) { render { Stack(gap: 1) { Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 3) { Text(heading, fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Text(count, fontSize: FontSize.Caption, color: Colors.TextSecondary); } Text(note, fontSize: FontSize.Caption, color: Colors.TextSecondary, maxW: "80ch"); } } } [Composable] component Card(string reference, string title, string detail, string notes) { render { Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) { Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) { Stack(gap: 1) { Text(reference + " — " + title, fontSize: FontSize.Body, fontWeight: FontWeight.Medium); Text(detail, fontSize: FontSize.Caption, color: Colors.TextSecondary); } Text(notes, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } }