wf-supplier-dispatch
wf-supplier-dispatch — CORRELATION: an inbound event finds its run by a BUSINESS KEY.
5 source files1 test file
Get it
// not one of the apps the toolchain ships: this one lives in the // repository. Clone it, then: $ cd demo/wf-supplier-dispatch $ osy launch
The app
app.osy6 lines
// wf-supplier-dispatch — CORRELATION: an inbound event finds its run by a BUSINESS KEY. app WfSupplierDispatch { model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/dispatch.osy68 lines
// DISPATCH — a parcel, and a carrier that has never heard of our workflow. enum ShipmentStatus { Booked, InTransit, OutForDelivery, Delivered, Lost } entity Shipment { [Required, Unique, MaxLength(60)] string TrackingNumber; [Required, MaxLength(200)] string Destination; ShipmentStatus Status; /// Where the carrier last saw it. Written by the workflow from the scan, never by a page. [MaxLength(200)] string? LastSeenAt; /// How many scans we have accepted for this parcel — the carrier's view of the journey, as we received it. int ScanCount; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } workflow ParcelDispatch { Tracks = Shipment.Status; Autostart = true; Initial = Booked; [CorrelateOn(this.Item.TrackingNumber == trackingNumber)] event Scanned(string trackingNumber, string location, ScanKind kind); [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Dispatcher))] event MarkLost(string why); on MarkLost(string why) { goto Lost; } state Booked { subscribe Scanned() as Collection; on Collection(string trackingNumber, string location, ScanKind kind) { this.Item.LastSeenAt = location; this.Item.ScanCount = this.Item.ScanCount + 1; goto InTransit; } } state InTransit { subscribe Scanned() as Movement; on Movement(string trackingNumber, string location, ScanKind kind) { this.Item.LastSeenAt = location; this.Item.ScanCount = this.Item.ScanCount + 1; if (kind == ScanKind.ArrivedAtFinalDepot) { goto OutForDelivery; } } } state OutForDelivery { subscribe Scanned() as Delivery; on Delivery(string trackingNumber, string location, ScanKind kind) { this.Item.LastSeenAt = location; this.Item.ScanCount = this.Item.ScanCount + 1; if (kind == ScanKind.Delivered) { goto Delivered; } } } terminal success Delivered { } terminal error Lost { } } /// What the carrier's scan MEANS — their vocabulary, not ours, because it arrives in their payload. enum ScanKind { Collected, InTransit, ArrivedAtFinalDepot, Delivered }
model/identity.osy54 lines
// Sign-in for the dispatch desk. The people here are OURS; the carrier is not a user of this app at all, and the // next file is about why that distinction decides how the carrier gets in. [Role] enum AppRole { Authenticator, Dispatcher } [Principal] entity Operator { [Required, MaxLength(100)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string PasswordHash; security { allow read when IsAuthenticated; allow read, create, update when IsAuthenticator; deny read PasswordHash when !IsAuthenticator; } } entity RoleGrant { Operator Grantee; [Required] AppRole Level; security { allow read when IsAuthenticated; allow read when IsAuthenticator; allow create when IsAuthenticator; } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsDispatcher => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Dispatcher); [AuthMethod] string Signup(string name, string email, string password) { var o = new Operator { Name = name, Email = email, PasswordHash = Security.HashPassword(password) }; new RoleGrant { Grantee = o, Level = AppRole.Dispatcher }; UnitOfWork.Commit(); return Security.IssueJwt(o.Id, o.Email); } [AuthMethod] string Login(string email, string password) { var o = Operator.Where(x => x.Email == email).FirstOrDefault(); if (o == null) { Security.VerifyPassword(password); return ""; } if (Security.VerifyPassword(password, o.PasswordHash)) { return Security.IssueJwt(o.Id, o.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, };
model/ingress.osy52 lines
// THE CARRIER'S DOOR — and the reason it is in THIS file rather than in the platform. app.Secrets = [ new Secret("CarrierWebhook") ]; /// What the carrier POSTs. THEIR shape, not ours — a webhook contract belongs to whoever sends it. class CarrierScan { [Required, MaxLength(60)] public string TrackingNumber; [Required, MaxLength(200)] public string Location; [Required] public ScanKind Kind; /// The carrier's HMAC-SHA256 over the tracking number and location, keyed with the secret we exchanged at /// onboarding. Every real carrier webhook has one of these under some name. [Required, MaxLength(200)] public string Signature; } /// What we answer the carrier. `Accepted` false with `Retry` true is how a sender is told to come back — which is /// what makes the NoSuchKey/NotRunning distinction below matter rather than being trivia. class ScanReceipt { public bool Accepted; public bool Retry; [MaxLength(200)] public string Message; } /// The endpoint. Anonymous to the app's own identity model — there is no Operator behind a carrier's POST and there /// never will be — and authenticated on the first line of its own body, by us. [AllowAnonymous] ScanReceipt Scan(CarrierScan scan) { var expected = Crypto.HmacSha256Hex(Secret.CarrierWebhook, scan.TrackingNumber + "|" + scan.Location); if (!Crypto.FixedTimeEquals(expected, scan.Signature)) { return new ScanReceipt { Accepted = false, Retry = false, Message = "signature rejected" }; } var outcome = ParcelDispatch.CorrelateScanned(scan.TrackingNumber, scan.Location, scan.Kind); if (outcome == CorrelationOutcome.Deposited) { return new ScanReceipt { Accepted = true, Retry = false, Message = "recorded" }; } if (outcome == CorrelationOutcome.NoSuchKey) { return new ScanReceipt { Accepted = false, Retry = true, Message = "unknown tracking number — try again later" }; } return new ScanReceipt { Accepted = false, Retry = false, Message = "this parcel is no longer in transit" }; } app.Apis = [ new RestApi("CarrierApi") { Route = "carrier", Version = "1.0", Auth = new ApiAuth { ApiKey = false }, Endpoints = [ new Endpoint(Scan) { Method = HttpMethod.Post, Path = "/scans", SuccessStatus = 200 }, ], }, ];
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. }