From 1aa3271d5a99c1d75bdbfdf462ce01c039ec9d01 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 19:18:31 +0200 Subject: [PATCH] Update the shared Cratis AI corpus to Cratis/AI 4bdf4be --- .cratis/ai.manifest.json | 59 ++-- .cratis/ai/agents/slice-implementer.md | 2 +- .../opencode/agents/slice-implementer.md | 2 +- .cratis/ai/hooks/README.md | 20 +- .../ai/hooks/scripts/cratis-quality-gate.sh | 43 ++- .cratis/ai/hooks/scripts/quality-gates.json | 53 ++- .cratis/ai/profile-catalog.json | 24 ++ .../ai/prompts/add-business-rule.prompt.md | 2 +- .cratis/ai/prompts/add-concept.prompt.md | 2 +- .cratis/ai/prompts/add-ef-migration.prompt.md | 2 +- .cratis/ai/prompts/add-projection.prompt.md | 2 +- .cratis/ai/prompts/add-reactor.prompt.md | 2 +- .cratis/ai/prompts/add-reducer.prompt.md | 2 +- .../ai/prompts/new-vertical-slice.prompt.md | 4 +- .cratis/ai/prompts/scaffold-feature.prompt.md | 4 +- .cratis/ai/prompts/write-specs.prompt.md | 2 +- .cratis/ai/rules/framework.md | 2 +- .cratis/ai/rules/profiles.md | 23 ++ .cratis/ai/rules/specs.csharp.md | 4 +- .../SKILL.md | 195 +++++++--- .../references/authentication.md | 60 +++- .../references/authorization.md | 332 ++++++++++++++---- .../references/frontend.md | 7 +- .../references/local-development.md | 9 +- .../references/observable-emission-guards.md | 105 ++++++ .../references/tenancy.md | 13 +- .../cratis-chronicle-cli-operations/SKILL.md | 8 +- .../skills/cratis-chronicle-reactor/SKILL.md | 6 +- 28 files changed, 805 insertions(+), 184 deletions(-) create mode 100644 .cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/observable-emission-guards.md diff --git a/.cratis/ai.manifest.json b/.cratis/ai.manifest.json index 66ce3c4e..fbf7ed6c 100644 --- a/.cratis/ai.manifest.json +++ b/.cratis/ai.manifest.json @@ -1,5 +1,5 @@ { - "SourceRevision": "cc847418c99ad8ba62d6372cace683d8d4a47db9", + "SourceRevision": "4bdf4be2c621c433e293abeb854b2ddec73c1096", "Files": [ { "Source": "agents/backend-developer.md", @@ -54,7 +54,7 @@ { "Source": "agents/slice-implementer.md", "Destination": "agents/slice-implementer.md", - "Hash": "FD0044C71902BBB3A7198F07598840043F42932161318D6F14A1A72CA285F84F" + "Hash": "86AAA35760B9FBAE4D7ADDA2F30405902CA2D8D119FAD25735A58AA8CAA96AE5" }, { "Source": "agents/spec-writer.md", @@ -119,7 +119,7 @@ { "Source": "harnesses/opencode/agents/slice-implementer.md", "Destination": "harnesses/opencode/agents/slice-implementer.md", - "Hash": "A2A0359472D78721567EDFD585DFCEFA1BE8D491870EB91D14D41D7861871FA1" + "Hash": "C05F0230D95436DFDCAA5D377492F263424E29E5C50D5E0958D2925D5722FA67" }, { "Source": "harnesses/opencode/agents/spec-writer.md", @@ -289,7 +289,7 @@ { "Source": "hooks/README.md", "Destination": "hooks/README.md", - "Hash": "7E6D15FCB27210070424FF0C3C29D8EC86810CC7A3E6A26695D9F4C9BAF43069" + "Hash": "C5B6E291FCE9DF6617F8CD52F59420BDE95C6EE553D033CEEC261311D2D294A1" }, { "Source": "hooks/agent-stop.md", @@ -329,7 +329,7 @@ { "Source": "hooks/scripts/cratis-quality-gate.sh", "Destination": "hooks/scripts/cratis-quality-gate.sh", - "Hash": "9E59C975C2DBBDC26795421963D3259B410C043E11ECC7D02B42A5E566CC35CE" + "Hash": "1869DEB708F793AEA3EC7F6E69EC703D32D4F8B4C3665B092FAE75100797D6A9" }, { "Source": "hooks/scripts/cratis-store-mutations.json", @@ -344,7 +344,7 @@ { "Source": "hooks/scripts/quality-gates.json", "Destination": "hooks/scripts/quality-gates.json", - "Hash": "4B47A74659733DA211BA24195C8DAE178F196681A30F1B70301CE79A46461C27" + "Hash": "230D92BBEEC2E4B58C3B44FFB1B5CAF3E0240E2677E4844503BC09E98B08F6C4" }, { "Source": "hooks/scripts/type-references-allowlist.txt", @@ -379,37 +379,37 @@ { "Source": "profile-catalog.json", "Destination": "profile-catalog.json", - "Hash": "465E60D02E0546129C07960C771A7FADB3CD68810A9587BB87773B463B9B7DA6" + "Hash": "E84C724ED25C8B264A52E7198C6113D2AA83E9DB090F218E3E1B6BF03683959F" }, { "Source": "prompts/add-business-rule.prompt.md", "Destination": "prompts/add-business-rule.prompt.md", - "Hash": "463CCED6B82FF09989861B6BB33F26BB9CAB6FEBB583CD021D659AF30C6C5949" + "Hash": "AB7DB051799958E4E7806F717376C826947806A4098FAD8C9E5513A001F64157" }, { "Source": "prompts/add-concept.prompt.md", "Destination": "prompts/add-concept.prompt.md", - "Hash": "662D846189D381805A98503A85C02D53BE95D569E9B7E5D8C64A4B9E02B3DA85" + "Hash": "A17517AF4B5AC2C0DED148878B9E46F401E3A3E5DC13C1338D5727401F7A27D3" }, { "Source": "prompts/add-ef-migration.prompt.md", "Destination": "prompts/add-ef-migration.prompt.md", - "Hash": "2DE328CE27A072AE6785A3F75507373B0FB8847CAAB43B24A9B176DF04FB1513" + "Hash": "9DBAB6B6170C5365E68D019A9DB482B4E346504EFAB3C0991CD943E333034F89" }, { "Source": "prompts/add-projection.prompt.md", "Destination": "prompts/add-projection.prompt.md", - "Hash": "A288007608654FE84BF613F2EDEEAAB019A2EDFF496421F6D80F7DE81EE7AF5C" + "Hash": "5B18E1780021A22AB0142F32BD3F18E4A79B87466E8CD0A97A0BACAB37C63D58" }, { "Source": "prompts/add-reactor.prompt.md", "Destination": "prompts/add-reactor.prompt.md", - "Hash": "EB58E4288E46A2B5752F0F0A6C490FF6C761AA6D2EF33707B0145A7BDBF4FCD3" + "Hash": "F3B00A09EA51807DD1664DA53B9840578BDA93E0641F600625A8AA3BB030D2AD" }, { "Source": "prompts/add-reducer.prompt.md", "Destination": "prompts/add-reducer.prompt.md", - "Hash": "565285224057F0BDAA84CA9A99E9000812FB429C27749A4A655AF4B9DD1AF295" + "Hash": "991564352BFD684CE06944CC537EBF3E07ECFBC042E0D5A84FE3E98FFE72318F" }, { "Source": "prompts/audit-hooks.prompt.md", @@ -434,7 +434,7 @@ { "Source": "prompts/new-vertical-slice.prompt.md", "Destination": "prompts/new-vertical-slice.prompt.md", - "Hash": "61721719C85181332074547EA5466B6442A34197A4369E451DAB8C5163220A19" + "Hash": "251E78CDD4ADFBA8D0B9A648691CD4301C76F5C1E3C4D8B74BA0B14428E6374F" }, { "Source": "prompts/review-pr.prompt.md", @@ -449,7 +449,7 @@ { "Source": "prompts/scaffold-feature.prompt.md", "Destination": "prompts/scaffold-feature.prompt.md", - "Hash": "1D813AC78D7C26636BB5FD6B89F993853686EA4770063B71D0FCEC75EBD6F009" + "Hash": "C30B71D7150B66E4365061F96DEAF555C36B431C704EC46637F01D190F8AF61B" }, { "Source": "prompts/ship-changes.prompt.md", @@ -469,7 +469,7 @@ { "Source": "prompts/write-specs.prompt.md", "Destination": "prompts/write-specs.prompt.md", - "Hash": "D0F9AFFF2E166E000282A848EE58EFD0BEE5E82A8954FFB1E92EA3C733F2296B" + "Hash": "DA033222DC35AD0E9594C76F932CF0F424D1E645ECC15E9668B5AD5CFB9CCF6C" }, { "Source": "rules/ai-distribution.md", @@ -529,7 +529,7 @@ { "Source": "rules/framework.md", "Destination": "rules/framework.md", - "Hash": "8D5DF1A80D4735E033A7489B5AC3CD8D50B8C778BE87F49D95925D8722D153EC" + "Hash": "B850ADA6A884C3ED64FB0EED13101BDA9847F5B3B6928229C38CC6DB441AA548" }, { "Source": "rules/general.md", @@ -584,7 +584,7 @@ { "Source": "rules/profiles.md", "Destination": "rules/profiles.md", - "Hash": "0147BEB4788AF092DE1FC53EABD0F0C22730BD4ECD6E04CF5EA6069382396569" + "Hash": "9026C942C012F6C081F717F1D0E74807CA76EE21C77575BDC1C2924A6C0251CF" }, { "Source": "rules/pull-requests.md", @@ -599,7 +599,7 @@ { "Source": "rules/specs.csharp.md", "Destination": "rules/specs.csharp.md", - "Hash": "08F62A454CA45EE666D175EC152C491EFD15F1662EAE590CD546828CB8D59C68" + "Hash": "EDAFBF16C7285504BB1E5C4DA1B6E2B04173F7CF01A2AB09434F45BEEBD5034D" }, { "Source": "rules/specs.md", @@ -654,32 +654,37 @@ { "Source": "skills/cratis-arc-authentication-authorization-and-identity/SKILL.md", "Destination": "skills/cratis-arc-authentication-authorization-and-identity/SKILL.md", - "Hash": "52348DC9A43D2B7C9DDD7BB869A718A6581FC478ADD8877D4A1357145D760E87" + "Hash": "6CE084AD19D821E3AAED7FF02B0AD5A6554D96E1ED190CB45CE5E6D6083D30C6" }, { "Source": "skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md", "Destination": "skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md", - "Hash": "F8CB60F2D8118C7B86F54B7A8E90CBBD7295B960A9181EE1817EAC882E7A06E8" + "Hash": "9DEF54D82BBE4C91861565513E512E862E50731EE7B0AB2F7004AE06AFDF51F6" }, { "Source": "skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md", "Destination": "skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md", - "Hash": "CC9E3FCE71D6F641F97191B5BE2F1C6BFE35CC83545644D8744545072B5D8850" + "Hash": "A9C51BA75977BC3A59C45EDA0CE0B64725ACF64900B93CA1E24ABE60C254F235" }, { "Source": "skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md", "Destination": "skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md", - "Hash": "539E69B83A6E55A25D02DF4A348C1C4B0F4ED00F92A9569E6B7D1791854A8DCF" + "Hash": "26A5F652423F181DB096946907560D44EA02F2BAA9ADE83AC9A7EC6F07BF5888" }, { "Source": "skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md", "Destination": "skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md", - "Hash": "FB4A37DE2A88CB6D38775F6396365EB68150F800F24B24DCEE07915C8455CFFB" + "Hash": "1E48DB3FEBD2FB3B4174E2DE562A5BF64E15EFE734BEFD41206C1B581992FA23" + }, + { + "Source": "skills/cratis-arc-authentication-authorization-and-identity/references/observable-emission-guards.md", + "Destination": "skills/cratis-arc-authentication-authorization-and-identity/references/observable-emission-guards.md", + "Hash": "BDBCF57ADCC524FB989DB0AAFB9D71A1FDCB4655D633A390C609E0B2E0A59D8E" }, { "Source": "skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md", "Destination": "skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md", - "Hash": "6D8515295E6306EFC3F6C17513D5681A119C1102B4729C70AD79E2D2B99F5DA8" + "Hash": "6DB4060D2F94D89EE70EEBB15ACE63094514CE5C1D907E71ED5C3DE45D1A51CB" }, { "Source": "skills/cratis-arc-command-execution/LICENSE", @@ -794,7 +799,7 @@ { "Source": "skills/cratis-chronicle-cli-operations/SKILL.md", "Destination": "skills/cratis-chronicle-cli-operations/SKILL.md", - "Hash": "545DF95A736CF1BD42ACFE3F9EA2EA4E811A97802286746F309BE2E39EED004F" + "Hash": "71433D310F2B90567F0BC239255B01DC9AA3ED995A84B92372D534DA53DAF821" }, { "Source": "skills/cratis-chronicle-client-typescript/LICENSE", @@ -869,7 +874,7 @@ { "Source": "skills/cratis-chronicle-reactor/SKILL.md", "Destination": "skills/cratis-chronicle-reactor/SKILL.md", - "Hash": "9C1D17036006DF56675EEA6BB5CEB59E6DCF79764AEECEA90D285183FC9F7046" + "Hash": "BA6793AE4577D5538BD2CF4E87BAB05F79B49E96E4D5DEB89277D7A8E0608B18" }, { "Source": "skills/cratis-chronicle-read-model/LICENSE", diff --git a/.cratis/ai/agents/slice-implementer.md b/.cratis/ai/agents/slice-implementer.md index 46e57b99..0e7f66f7 100644 --- a/.cratis/ai/agents/slice-implementer.md +++ b/.cratis/ai/agents/slice-implementer.md @@ -39,7 +39,7 @@ A new vertical slice (State Change, State View, Automation, Translation), or a n ## Workflow — phase gates; don't start the next until the current passes ### Phase 1 — Plan -For new behavior, unclear event names/stream boundaries, or multi-slice flows, run the `event-modeling` skill first. Confirm Module/Feature/slice name + type, the behavior in one sentence, whether a UI surface is needed, and the event/read-model/scenario outline. Ask only when a real product/domain choice can't be answered from the repo. +For new behavior, unclear event names/stream boundaries, or multi-slice flows, run the **cratis-chronicle-event-modeling** skill first. Confirm Module/Feature/slice name + type, the behavior in one sentence, whether a UI surface is needed, and the event/read-model/scenario outline. Ask only when a real product/domain choice can't be answered from the repo. ### Phase 2 — Backend Write `///.cs` with all backend artifacts (declaration order per `general.md`). **Gate:** build clean in **Debug and Release** (zero errors/warnings — Debug validates `#if DEBUG` spec code and regenerates the TypeScript proxies; build Release with `-p:CratisProxiesOutputPath=` to skip re-running proxy generation). diff --git a/.cratis/ai/harnesses/opencode/agents/slice-implementer.md b/.cratis/ai/harnesses/opencode/agents/slice-implementer.md index 069086a8..58a82f2b 100644 --- a/.cratis/ai/harnesses/opencode/agents/slice-implementer.md +++ b/.cratis/ai/harnesses/opencode/agents/slice-implementer.md @@ -36,7 +36,7 @@ A new vertical slice (State Change, State View, Automation, Translation), or a n ## Workflow — phase gates; don't start the next until the current passes ### Phase 1 — Plan -For new behavior, unclear event names/stream boundaries, or multi-slice flows, run the `event-modeling` skill first. Confirm Module/Feature/slice name + type, the behavior in one sentence, whether a UI surface is needed, and the event/read-model/scenario outline. Ask only when a real product/domain choice can't be answered from the repo. +For new behavior, unclear event names/stream boundaries, or multi-slice flows, run the **cratis-chronicle-event-modeling** skill first. Confirm Module/Feature/slice name + type, the behavior in one sentence, whether a UI surface is needed, and the event/read-model/scenario outline. Ask only when a real product/domain choice can't be answered from the repo. ### Phase 2 — Backend Write `///.cs` with all backend artifacts (declaration order per `general.md`). **Gate:** build clean in **Debug and Release** (zero errors/warnings — Debug validates `#if DEBUG` spec code and regenerates the TypeScript proxies; build Release with `-p:CratisProxiesOutputPath=` to skip re-running proxy generation). diff --git a/.cratis/ai/hooks/README.md b/.cratis/ai/hooks/README.md index f64b2bc1..057d9404 100644 --- a/.cratis/ai/hooks/README.md +++ b/.cratis/ai/hooks/README.md @@ -341,10 +341,26 @@ script. A consuming repository customises all three without forking anything: | `scripts/cratis-store-mutations.json` | the store-mutation guard's read-only allowlist and known-mutating list, each entry with its reason or effect, and the CLI options it skips; its header `$comment` documents every field and the CLI version the lists were derived from | | `scripts/cratis-store-mutations.local.json` | optional; its lists are appended to the above. It can classify a command a newer CLI adds, or list a shipped read-only command as mutating (a command on the mutating list always blocks); only a replacement file can make a known mutation read-only | -A gate whose `requires.commands` are not on `PATH`, whose `requires.paths` do not exist, or whose -`workingDirectoryFrom` matches nothing in the repository, is a **no-op with a message on stderr** +A gate whose `requires.commands` are not on `PATH`, whose `requires.paths` do not exist, whose +`requires.packageScripts` are not defined in the `package.json` of its working directory (a `g:` +script may instead be defined in the root `package.json` — this assumes Yarn Berry, where a +root-defined `g:` script runs from every workspace, and no workspace `package.json` is inspected), or whose `workingDirectoryFrom` matches +nothing in the repository, is a **no-op with a message on stderr** rather than a failure — that is how a repository with no .NET solution or no frontend stays quiet. +An override in `.cratis/ai/quality-gates.project.json` is merged shallowly: a field it states +replaces the managed one. The one exception is `requires.packageScripts`: an override that sets +`command` without stating `requires` drops the managed gate's `packageScripts`, because they guard +the managed command (`yarn g:compile`), not the replacement. Without that, a repository replacing +`command` would silently turn the gate into a no-op whenever it lacks the managed script. An +override that does state `requires` replaces it whole, so list `packageScripts` again if the new +command needs them. + +Every shipped gate ignores the managed corpus and adapter trees (`.cratis/**`, `.pi/**`, +`.claude/**`) through `excludeChanged`, so a `cratis ai update` never runs a repository's application +gates. A repository whose own source lives there, like this corpus, restates `excludeChanged` for the +gates it needs in `.cratis/ai/quality-gates.project.json`. + **No shipped gate names a product's file.** A default that did would activate in exactly one repository and silently no-op in every other, which is the worst of both: it looks configured and checks nothing. So the .NET and frontend gates state *what kind of project* they build and let the diff --git a/.cratis/ai/hooks/scripts/cratis-quality-gate.sh b/.cratis/ai/hooks/scripts/cratis-quality-gate.sh index 17f27077..d5e79d2a 100644 --- a/.cratis/ai/hooks/scripts/cratis-quality-gate.sh +++ b/.cratis/ai/hooks/scripts/cratis-quality-gate.sh @@ -48,6 +48,11 @@ jq -e . "$config" >/dev/null 2>&1 || { # quality-gates.json mixes project facts into Cratis-owned content, so the next managed update # either reports drift or silently discards the repository's own configuration. The override # states only what differs, keyed by gate id, and nothing here needs a script fork. +# +# The merge is shallow (a patch field replaces the base field), with one exception: a patch that +# replaces `command` but does not state `requires` also drops the base gate's +# requires.packageScripts. Those scripts guard the base command; keeping them would make the +# overriding repository's own command a silent NO-OP whenever it lacks e.g. g:compile. overrides="$root/.cratis/ai/quality-gates.project.json" if [ -f "$overrides" ]; then if jq -e . "$overrides" >/dev/null 2>&1; then @@ -55,11 +60,16 @@ if [ -f "$overrides" ]; then if jq -s ' .[0] as $base | .[1] as $over | ($over.gates // []) as $gates - | $base + | def patched($gate; $patch): + ($gate + ($patch | del(.id))) as $m + | if ($patch | has("command")) and (($patch | has("requires")) | not) and ($m.requires != null) + then $m | .requires |= del(.packageScripts) + else $m end; + $base + ($over | del(.gates)) + { gates: [ $base.gates[] as $gate | ($gates | map(select(.id == $gate.id)) | first) as $patch - | if $patch == null then $gate else $gate + ($patch | del(.id)) end ] } + | if $patch == null then $gate else patched($gate; $patch) end ] } ' "$config" "$overrides" >"$merged" 2>/dev/null; then unknown="$(jq -r --slurpfile base "$config" '[.gates // [] | .[].id] - [$base[0].gates[].id] | .[]' "$overrides" 2>/dev/null || true)" [ -n "$unknown" ] && printf 'cratis-quality-gate: %s overrides unknown gate(s): %s\n' \ @@ -152,9 +162,16 @@ EOF return 1 } -# Report the first unmet requirement, or nothing when the gate can run here. +# Does the package.json at $1 define the script named $2? A missing file or a file that is not +# valid JSON defines nothing. +package_defines_script() { + [ -f "$1" ] && jq -e --arg s "$2" '.scripts[$s] != null' "$1" >/dev/null 2>&1 +} + +# Report the first unmet requirement, or nothing when the gate can run here. $2 is the directory, +# relative to the repository root, the gate runs in. gate_unmet() { - local idx="$1" c p + local idx="$1" dir="$2" c p s while IFS= read -r c; do [ -n "$c" ] || continue hook_have "$c" || { printf "command '%s' is not on PATH" "$c"; return 0; } @@ -166,6 +183,22 @@ EOF [ -e "$root/$p" ] || { printf "'%s' does not exist in this repository" "$p"; return 0; } done <&2 diff --git a/.cratis/ai/hooks/scripts/quality-gates.json b/.cratis/ai/hooks/scripts/quality-gates.json index ae2990cc..93b070b1 100644 --- a/.cratis/ai/hooks/scripts/quality-gates.json +++ b/.cratis/ai/hooks/scripts/quality-gates.json @@ -16,6 +16,11 @@ " excludeChanged globs that never count as a relevant change", " requires.commands executables that must be on PATH", " requires.paths repo-relative literal paths that must all exist (no globbing)", + " requires.packageScripts package.json scripts that must be defined in the gate\u0027s working directory", + " (a g: script may instead be defined in the repository root package.json, which assumes", + " Yarn Berry, where a root-defined g: script runs from every workspace; no workspace", + " package.json is inspected); a missing script makes the gate a NO-OP instead of a", + " \u0022Couldn\u0027t find a script\u0022 failure", " workingDirectory repo-relative directory the command runs in", " workingDirectoryFrom globs identifying the project file this gate builds; the command runs", " in that file\u0027s directory, and a repository holding none makes the gate a", @@ -23,6 +28,9 @@ " when workingDirectory is set.", " command argv array \u2014 executed directly, never through a shell (no eval)", "", + "Managed corpus and adapter trees (.cratis/**, .pi/**, .claude/**) never count as a relevant change:", + "a corpus update must not run a repository\u0027s application gates over files that are not its application.", + "", "No gate names a product\u0027s solution, package or source root \u2014 a default that does would", "silently no-op in every repository except the one it was written for. The .NET and frontend", "gates discover the repository\u0027s own solution/package instead, so they activate unchanged in", @@ -33,7 +41,9 @@ "own .cratis/ai/quality-gates.project.json, which the runner merges over this file by gate id", "(a project fact belongs there, never edited into this managed file, or the next managed update", "reports drift or discards it); or it points CRATIS_HOOKS_GATES at a file anywhere. Neither", - "requires a script fork.", + "requires a script fork. The merge is shallow: a field the override states replaces the managed one.", + "The one exception: an override that sets \u0022command\u0022 without \u0022requires\u0022 also drops this gate\u0027s", + "requires.packageScripts, since those guard the managed command, not the override\u0027s.", "", "Example .cratis/ai/quality-gates.project.json, for a repository whose frontend is not at the root:", " { \u0022gates\u0022: [ { \u0022id\u0022: \u0022frontend-lint\u0022, \u0022workingDirectory\u0022: \u0022Source/App\u0022 } ] }", @@ -64,7 +74,10 @@ ], "excludeChanged": [ "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ @@ -95,7 +108,10 @@ ], "excludeChanged": [ "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ @@ -129,7 +145,10 @@ ], "excludeChanged": [ "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ @@ -162,7 +181,10 @@ "**/wwwroot/**", "**/dist/**", "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ @@ -190,11 +212,17 @@ "**/wwwroot/**", "**/dist/**", "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ "yarn" + ], + "packageScripts": [ + "g:compile" ] }, "workingDirectoryFrom": [ @@ -218,11 +246,17 @@ "**/wwwroot/**", "**/dist/**", "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ "yarn" + ], + "packageScripts": [ + "g:compile:specs" ] }, "workingDirectoryFrom": [ @@ -246,7 +280,10 @@ "**/wwwroot/**", "**/dist/**", "**/obj/**", - "**/bin/**" + "**/bin/**", + ".cratis/**", + ".pi/**", + ".claude/**" ], "requires": { "commands": [ diff --git a/.cratis/ai/profile-catalog.json b/.cratis/ai/profile-catalog.json index 43d5eae4..143159ad 100644 --- a/.cratis/ai/profile-catalog.json +++ b/.cratis/ai/profile-catalog.json @@ -23,6 +23,8 @@ "cratis/arc/java", "cratis/arc/kotlin", "cratis/arc/react", + "cratis/arc/server-typescript", + "cratis/arc/typescript", "cratis/chronicle", "cratis/chronicle/client-dotnet", "cratis/chronicle/client-elixir", @@ -335,6 +337,27 @@ "typescript" ] }, + { + "id": "cratis/arc/server-typescript", + "availableTargets": [ + "cratis-arc-command-typescript", + "cratis-arc-query-typescript", + "cratis-arc-validation-typescript" + ], + "languages": [ + "typescript" + ] + }, + { + "id": "cratis/arc/typescript", + "composes": [ + "cratis/arc/server-typescript", + "cratis/language/typescript" + ], + "languages": [ + "typescript" + ] + }, { "id": "cratis/chronicle", "composes": [ @@ -666,6 +689,7 @@ "id": "cratis/full/typescript", "composes": [ "cratis/application/typescript", + "cratis/arc/typescript", "cratis/chronicle/typescript", "cratis/modeling/screenplay-stage" ], diff --git a/.cratis/ai/prompts/add-business-rule.prompt.md b/.cratis/ai/prompts/add-business-rule.prompt.md index 97fe6769..579c0b81 100644 --- a/.cratis/ai/prompts/add-business-rule.prompt.md +++ b/.cratis/ai/prompts/add-business-rule.prompt.md @@ -6,7 +6,7 @@ description: Add a business rule or event-store constraint to an existing comman # Add a Business Rule or Constraint -Enforce a new rule on an existing command. Invoke the **add-business-rule** skill and follow `.cratis/ai/rules/vertical-slices.md` (the decision matrix). +Enforce a new rule on an existing command. Invoke the **cratis-arc-command-validation** skill, or the **cratis-chronicle-event-constraints** skill for uniqueness and other append-time constraints, and follow `.cratis/ai/rules/vertical-slices.md` (the decision matrix). ## Confirm first diff --git a/.cratis/ai/prompts/add-concept.prompt.md b/.cratis/ai/prompts/add-concept.prompt.md index ad4a658e..ef273fae 100644 --- a/.cratis/ai/prompts/add-concept.prompt.md +++ b/.cratis/ai/prompts/add-concept.prompt.md @@ -6,7 +6,7 @@ description: Create a strongly-typed Concept for a primitive domain value or an # Add a Concept -Create a strongly-typed concept to replace a raw primitive. Invoke the **add-concept** skill and follow `.cratis/ai/rules/concepts.md`. +Create a strongly-typed concept to replace a raw primitive. Invoke the **cratis-fundamentals-concept** skill and follow `.cratis/ai/rules/concepts.md`. ## Confirm first diff --git a/.cratis/ai/prompts/add-ef-migration.prompt.md b/.cratis/ai/prompts/add-ef-migration.prompt.md index 773ce676..aa752d32 100644 --- a/.cratis/ai/prompts/add-ef-migration.prompt.md +++ b/.cratis/ai/prompts/add-ef-migration.prompt.md @@ -6,7 +6,7 @@ description: Add or update an Entity Framework Core DbContext, table column, or # Add an EF Core Migration -Make a database schema change via EF Core. Invoke the **add-ef-migration** skill and follow `.cratis/ai/rules/efcore.md` (+ `.cratis/ai/rules/efcore.specs.md`). +Make a database schema change via EF Core. Invoke the **cratis-arc-ef-core-migration** skill and follow `.cratis/ai/rules/efcore.md` (+ `.cratis/ai/rules/efcore.specs.md`). > Applies only to projects that use EF Core. diff --git a/.cratis/ai/prompts/add-projection.prompt.md b/.cratis/ai/prompts/add-projection.prompt.md index 3e45188a..0281db01 100644 --- a/.cratis/ai/prompts/add-projection.prompt.md +++ b/.cratis/ai/prompts/add-projection.prompt.md @@ -6,7 +6,7 @@ description: Add a Chronicle projection to an existing read model slice. # Add a Projection -Add a Chronicle projection that populates a read model from events. Invoke the **add-projection** skill and follow `.cratis/ai/rules/vertical-slices.md` (projections). For reactors, use the **add-reactor** prompt instead. +Add a Chronicle projection that populates a read model from events. Invoke the **cratis-chronicle-projection** skill and follow `.cratis/ai/rules/vertical-slices.md` (projections). For reactors, use the **add-reactor** prompt instead. ## Confirm first diff --git a/.cratis/ai/prompts/add-reactor.prompt.md b/.cratis/ai/prompts/add-reactor.prompt.md index 7fab3a82..3ccc97bd 100644 --- a/.cratis/ai/prompts/add-reactor.prompt.md +++ b/.cratis/ai/prompts/add-reactor.prompt.md @@ -6,7 +6,7 @@ description: Add a Chronicle reactor (automation or translation) that reacts to # Add a Reactor -Add a reactor that observes events and produces side effects. Invoke the **add-reactor** skill and follow `.cratis/ai/rules/reactors.md`. +Add a reactor that observes events and produces side effects. Invoke the **cratis-chronicle-reactor** skill and follow `.cratis/ai/rules/reactors.md`. ## Confirm first diff --git a/.cratis/ai/prompts/add-reducer.prompt.md b/.cratis/ai/prompts/add-reducer.prompt.md index 14ff1dd5..c87b78ef 100644 --- a/.cratis/ai/prompts/add-reducer.prompt.md +++ b/.cratis/ai/prompts/add-reducer.prompt.md @@ -6,7 +6,7 @@ description: Add a Chronicle reducer to a read model when model-bound and fluent # Add a Reducer -Add an `IReducerFor` reducer — the last-resort escape hatch for a "current state + event → next state" transition that model-bound projection attributes and fluent `IProjectionFor` cannot express. Invoke the **add-reducer** skill and follow `.cratis/ai/rules/vertical-slices.md`. For ordinary projections, use the **add-projection** prompt instead. +Add an `IReducerFor` reducer — the last-resort escape hatch for a "current state + event → next state" transition that model-bound projection attributes and fluent `IProjectionFor` cannot express. Invoke the **cratis-chronicle-reducer** skill and follow `.cratis/ai/rules/vertical-slices.md`. For ordinary projections, use the **add-projection** prompt instead. ## Confirm first diff --git a/.cratis/ai/prompts/new-vertical-slice.prompt.md b/.cratis/ai/prompts/new-vertical-slice.prompt.md index 7f9d1b4b..1064e670 100644 --- a/.cratis/ai/prompts/new-vertical-slice.prompt.md +++ b/.cratis/ai/prompts/new-vertical-slice.prompt.md @@ -6,7 +6,7 @@ description: Scaffold a complete vertical slice (backend + specs + frontend) for # New Vertical Slice -Implement a complete **vertical slice** end-to-end. Invoke the **new-vertical-slice** skill and follow it exactly; for a full backend + specs + frontend slice you may hand the work to the **Slice Implementer** agent. +Implement a complete **vertical slice** end-to-end. Follow the Implementation Workflow in `.cratis/ai/rules/application-profile.md` exactly; for a full backend + specs + frontend slice you may hand the work to the **Slice Implementer** agent. ## Confirm first @@ -16,4 +16,4 @@ Implement a complete **vertical slice** end-to-end. Invoke the **new-vertical-sl ## How it runs -Backend → build (Debug + Release) → specs (the in-process `*Scenario` family) → frontend → compose/route, with each quality gate green before the next phase. The authoritative rules are `.cratis/ai/rules/general.md` and `.cratis/ai/rules/vertical-slices.md`; the skill carries the step-by-step detail. Do not duplicate that detail here. +Backend → build (Debug + Release) → specs (the in-process `*Scenario` family) → frontend → compose/route, with each quality gate green before the next phase. The authoritative rules are `.cratis/ai/rules/general.md` and `.cratis/ai/rules/vertical-slices.md`; the rules carry the step-by-step detail. Do not duplicate that detail here. diff --git a/.cratis/ai/prompts/scaffold-feature.prompt.md b/.cratis/ai/prompts/scaffold-feature.prompt.md index b0b99612..5b11fb67 100644 --- a/.cratis/ai/prompts/scaffold-feature.prompt.md +++ b/.cratis/ai/prompts/scaffold-feature.prompt.md @@ -6,7 +6,7 @@ description: Scaffold a new feature — folder structure, composition page, rout # Scaffold a Feature -Scaffold a brand-new feature folder (composition page, routing, navigation) — ready for slices. Invoke the **scaffold-feature** skill and follow it exactly. +Scaffold a brand-new feature folder (composition page, routing, navigation) — ready for slices. Follow the Project Layout in `.cratis/ai/rules/application-profile.md` exactly, and Phase 3 of its Implementation Workflow plus the Composition section of `.cratis/ai/rules/react.md` for the composition page and routing. Invoke the **cratis-arc-react-page** skill for the page itself. ## Confirm first @@ -14,4 +14,4 @@ Scaffold a brand-new feature folder (composition page, routing, navigation) — - **Route path** — kebab-case (e.g. `/projects`) - **Navigation label** and **icon** (from `react-icons/md`) -The feature folder lives directly under the app source root (or under an optional `/`) — there is no top-level `Features/` wrapper. After scaffolding, add behavior with the **new-vertical-slice** prompt/skill. The skill carries the step-by-step detail; don't duplicate it here. +The feature folder lives directly under the app source root (or under an optional `/`) — there is no top-level `Features/` wrapper. After scaffolding, add behavior with the **new-vertical-slice** prompt. The rules carry the step-by-step detail; don't duplicate it here. diff --git a/.cratis/ai/prompts/write-specs.prompt.md b/.cratis/ai/prompts/write-specs.prompt.md index b29aa424..ab8fb289 100644 --- a/.cratis/ai/prompts/write-specs.prompt.md +++ b/.cratis/ai/prompts/write-specs.prompt.md @@ -6,7 +6,7 @@ description: Write comprehensive BDD specs for an existing vertical slice comman # Write Specs -Write **comprehensive specs** for an existing slice. Invoke the **write-specs** skill (and `write-specs-events` / `write-specs-readmodels` for constraints and projections); follow `.cratis/ai/rules/specs.md` and `.cratis/ai/rules/specs.csharp.md`. +Write **comprehensive specs** for an existing slice. Invoke the **cratis-application-slice-specifications** skill (and **cratis-chronicle-event-specifications** / **cratis-chronicle-read-model-specifications** for constraints and projections); follow `.cratis/ai/rules/specs.md` and `.cratis/ai/rules/specs.csharp.md`. ## What to provide diff --git a/.cratis/ai/rules/framework.md b/.cratis/ai/rules/framework.md index 16fac369..85bd8877 100644 --- a/.cratis/ai/rules/framework.md +++ b/.cratis/ai/rules/framework.md @@ -12,7 +12,7 @@ The framework repos are **libraries**, not event-sourced applications. The appli ## What still applies (universal rules) -Everything tagged `profile: universal` holds in framework repos exactly as in apps: **C# conventions** (`csharp.md`), **TypeScript conventions** (`typescript.md`), **code quality** (`code-quality*.md`), **specs** (`specs.md` / `specs.csharp.md` / `specs.typescript.md` — `Cratis.Specifications` is how the framework tests itself, using the plain `Specification` base + NSubstitute against the classes under test — this is the dominant mode in framework repos. The in-process `*Scenario` family lives in `specs.scenarios.csharp.md` (`profile: application`); it is the *application* default, and a framework repo reaches for it only to test the very engine it provides — Arc → `CommandScenario`, Chronicle → `EventScenario`/`ReadModelScenario`/`ReactorScenario` — not as a general testing mode. The **write-specs** skill is application-oriented), **documentation**, **git-commits**, **pull-requests**, **concepts** (`ConceptAs` / `EventSourceId` are Fundamentals/Chronicle primitives the framework defines and uses), and **American English**. +Everything tagged `profile: universal` holds in framework repos exactly as in apps: **C# conventions** (`csharp.md`), **TypeScript conventions** (`typescript.md`), **code quality** (`code-quality*.md`), **specs** (`specs.md` / `specs.csharp.md` / `specs.typescript.md` — `Cratis.Specifications` is how the framework tests itself, using the plain `Specification` base + NSubstitute against the classes under test — this is the dominant mode in framework repos. The in-process `*Scenario` family lives in `specs.scenarios.csharp.md` (`profile: application`); it is the *application* default, and a framework repo reaches for it only to test the very engine it provides — Arc → `CommandScenario`, Chronicle → `EventScenario`/`ReadModelScenario`/`ReactorScenario` — not as a general testing mode. The **cratis-application-slice-specifications** skill is application-oriented), **documentation**, **git-commits**, **pull-requests**, **concepts** (`ConceptAs` / `EventSourceId` are Fundamentals/Chronicle primitives the framework defines and uses), and **American English**. ## What does NOT apply diff --git a/.cratis/ai/rules/profiles.md b/.cratis/ai/rules/profiles.md index 286944e0..dcb3614f 100644 --- a/.cratis/ai/rules/profiles.md +++ b/.cratis/ai/rules/profiles.md @@ -111,6 +111,29 @@ Standalone Chronicle client usage (no Arc) for Kotlin and Java is `cratis-chronicle-client-kotlin`, reused by `cratis/chronicle/client-java`. JVM language conventions are `kotlin.md` and `java.md`. +### Arc for TypeScript on Node.js + +Arc for TypeScript (`@cratis/arc.core` with the Express, Fastify, Hono, +MongoDB, Drizzle, testing and experimental Chronicle packages) brings the same +model-bound command/query shape to a Node.js server. Its packages are a source +preview and are not published to npm. It is separate from `@cratis/arc`, the +published client runtime that generated proxies import in a frontend. +`cratis/arc/server-typescript` carries the skills; `cratis/arc/typescript` adds +the TypeScript language conventions, and `cratis/full/typescript` composes it. +The server skills are opt-in: `cratis/arc`, `cratis/application` and +`cratis/application/typescript` do not load them, because they target a +Node.js server and a C# or React-frontend project has no use for them. Select +`cratis/arc/typescript` (or `cratis/full/typescript`) for a Node.js Arc server. + +| Skill | Covers | +| --- | --- | +| `cratis-arc-command-typescript` | Project setup from the source preview, `ArcApplication.createBuilder()`, `@command()` with `handle()`/`provide()`, outcomes, authorization, returned Chronicle events, generated metadata and proxies, hosts, `CommandScenario` | +| `cratis-arc-query-typescript` | `@readModel()` with static `@query()` methods, argument binding, GET and HTTP `QUERY`, paging, RxJS observable queries, MongoDB/Drizzle/Chronicle read models, `QueryScenario`/`ObservableQueryScenario` | +| `cratis-arc-validation-typescript` | `CommandValidator`/`QueryValidator`/`ConceptValidator`/`ModelValidator`, `readModelForValidation`, `validation()` with `rejected`/`denied`, severity filtering | + +Standalone Chronicle client usage (no Arc) in TypeScript is +`cratis-chronicle-client-typescript`. + ### Specialized Profiles | Profile ID | Description | diff --git a/.cratis/ai/rules/specs.csharp.md b/.cratis/ai/rules/specs.csharp.md index 787bd7df..6be85e18 100644 --- a/.cratis/ai/rules/specs.csharp.md +++ b/.cratis/ai/rules/specs.csharp.md @@ -11,8 +11,8 @@ paths: Extends the base [specs.md](./specs.md) (folder structure, naming, philosophy) with C#-specific conventions. This file is the **universal foundation** used in **both** profiles — the `Cratis.Specifications` pattern (`Establish → Because → should_`) with NSubstitute. > **Which spec surface?** -> - **Framework / library code** (Chronicle kernel, Arc pipeline, source generators, Fundamentals): the `Specification` base + NSubstitute in this file is the dominant mode — unit-test the classes under test in isolation. Reach for a `*Scenario` helper (in [specs.scenarios.csharp.md](./specs.scenarios.csharp.md)) **only** when testing the very engine your repo provides — Arc → `CommandScenario`, Chronicle → the event/projection/reactor scenarios. The **write-specs** skill is application-oriented, not for general framework specs. -> - **Event-sourced applications on Cratis** (commands, projections, reducers, reactors, constraints): use this base **plus** the in-process scenario family — see **[specs.scenarios.csharp.md](./specs.scenarios.csharp.md)** (`profile: application`) and the **write-specs** skill. +> - **Framework / library code** (Chronicle kernel, Arc pipeline, source generators, Fundamentals): the `Specification` base + NSubstitute in this file is the dominant mode — unit-test the classes under test in isolation. Reach for a `*Scenario` helper (in [specs.scenarios.csharp.md](./specs.scenarios.csharp.md)) **only** when testing the very engine your repo provides — Arc → `CommandScenario`, Chronicle → the event/projection/reactor scenarios. The **cratis-application-slice-specifications** skill is application-oriented, not for general framework specs. +> - **Event-sourced applications on Cratis** (commands, projections, reducers, reactors, constraints): use this base **plus** the in-process scenario family — see **[specs.scenarios.csharp.md](./specs.scenarios.csharp.md)** (`profile: application`) and the **cratis-application-slice-specifications** skill. `Cratis.Specifications` keeps the approach of Machine.Specifications (MSpec): `Establish → Because → should_` maps to "Given → When → Then" and keeps each spec focused on *one setup, one action, one set of assertions*. diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md index 7cc04a35..16388110 100644 --- a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md @@ -1,6 +1,6 @@ --- name: cratis-arc-authentication-authorization-and-identity -description: Wire authentication, authorization and identity in a Cratis Arc application — IProvideIdentityDetails and the /.cratis/me endpoint, the [Authorize]/[Roles]/[AllowAnonymous] attributes Arc actually evaluates, IAuthenticationHandler, the Microsoft Identity Platform header contract, tenant resolution, and the React identity hooks. Use for Arc identity providers, endpoint protection, roles, or frontend identity integration. Do not use for isolated command validation, and do not use for Chronicle tenant namespaces alone. +description: Wire authentication, authorization and identity in a Cratis Arc application — IProvideIdentityDetails and the /.cratis/me endpoint, Arc's [Authorize]/[Roles]/[AllowAnonymous] attributes and named policies, fallback baselines, observable-query emission guards, IAuthenticationHandler, the Microsoft Identity Platform header contract and its ingress requirements, discovery and introspection exposure, tenant resolution, and the React identity hooks. Use for Arc identity providers, endpoint protection, roles, policies, or frontend identity integration. Do not use for isolated command validation, and do not use for Chronicle tenant namespaces alone. license: MIT --- @@ -16,12 +16,12 @@ told about them. Each has its own extension point. | Package | Version | Purpose | | --- | --- | --- | -| `Cratis.Arc.Core` | `22.16.0` | `Cratis.Arc.Identity`, `Cratis.Arc.Authorization`, `Cratis.Arc.Authentication`, `Cratis.Arc.Tenancy` | -| `Cratis.Arc` | `22.16.0` | ASP.NET Core hosting, `AddMicrosoftIdentityPlatformIdentityAuthentication` | -| `@cratis/arc` | `22.16.0` | `IdentityProvider`, `IIdentity` | -| `@cratis/arc.react` | `22.16.0` | `IdentityProvider` component, `useIdentity`, `RequireRole` | +| `Cratis.Arc.Core` | `22.41.1` | `Cratis.Arc.Identity`, `Cratis.Arc.Authorization`, `Cratis.Arc.Authentication`, `Cratis.Arc.Tenancy` | +| `Cratis.Arc` | `22.41.1` | ASP.NET Core hosting, `AddMicrosoftIdentityPlatformIdentityAuthentication` | +| `@cratis/arc` | `22.41.1` | `IdentityProvider`, `IIdentity` | +| `@cratis/arc.react` | `22.41.1` | `IdentityProvider` component, `useIdentity`, `RequireRole` | -> Re-verified at the versions above by **symbol and signature**: every type, attribute and member this skill names exists at that tag, and the public surface it describes is unchanged since the previous verification (Chronicle 16.45.x / Arc 22.10.4 — the Chronicle 16→18 client diff is converters, options and doc comments; no type was removed or renamed). Behavior claims were verified at the earlier tag unless a section says otherwise. +> Re-verified at `22.41.1` (the latest release when written) against that tag's source and Arc's documentation: the types, attributes and members this skill names exist, and the authorization, introspection, identity and emission-guard behavior it describes matches them. **Authorization behavior changed materially after `22.16.0`**, the version this skill used to pin: named policies, schemes, fallback baselines, guest policies and Microsoft-attribute enforcement all arrived later. [authorization](references/authorization.md) has a version table; check it if you run an older version. Reverify before claiming support for another version. @@ -36,12 +36,23 @@ That is not a defect — it is the EasyAuth/reverse-proxy model, where the ingre is the trust boundary. But it means: - the application must be unreachable except through the ingress that sets those - headers; + headers, and that ingress must **authenticate callers, strip any + caller-supplied identity headers, write its own trusted replacements, and block + direct access to the backend**; - nothing Arc gives you makes the forwarded values authentic; - Arc ships **no** OpenID Connect and **no** JWT bearer integration. If you need the application itself to validate a token, that is ordinary ASP.NET Core authentication you configure yourself. +Today the default is that a plain `builder.AddCratis()` host registers the +unsigned-header scheme as its **default** authentication scheme, so any caller who +can reach it can forge a principal for every authorized command and query (open +Arc#2946). Run it only behind that ingress; otherwise use `AddCratisArc()` and +configure a credential-validating scheme. Arc itself refuses the unsigned headers +in one place — protected introspection — until +`Cratis:Arc:Introspection:TrustForwardedIdentityHeaders` is set; see +[authentication](references/authentication.md). + ## Identity details — what the frontend is told Implement `IProvideIdentityDetails` to shape what `/.cratis/me` returns: @@ -76,13 +87,31 @@ public class IdentityDetailsProvider : IProvideIdentityDetails<
> | Route | What it does | | --- | --- | | `GET /.cratis/me` | The current identity. 401 when not authenticated, 403 when the provider says not authorized, otherwise the result plus the identity cookie | -| `GET /.cratis/identity-details/schema` | The JSON schema of the details type | +| `GET /.cratis/identity-details/schema` | The JSON schema of the details type (`{}` without a details provider) | | `GET /.cratis/users` | Development tooling — aggregates every `ICanProvideUsers` | | `GET /.cratis/tenants` | Development tooling — aggregates every `ICanProvideTenants` | +| `GET /.cratis/commands`, `GET /.cratis/queries` | Introspection catalogs of operation names, types and routes (`MapIntrospectionEndpoints()`) | + +Normal `UseCratisArc()` activation maps all of these `AllowAnonymous` by default, +**including in Production**, unless an endpoint of the same name already exists; `/.cratis/me` +does its own 401/403. There is no public constant for the identity routes — they +are string literals in the endpoint mapper. + +⚠️ **A fallback policy does not protect an endpoint that is explicitly anonymous**, +and the word "development" is not an environment check. Today the default is that +these routes are public and expose your command, query, user and tenant surface; +the exposure is tracked in open Arc#2834. To change it: -All four are mapped `AllowAnonymous`; `/.cratis/me` does its own 401/403. There -is no public constant for these routes — they are string literals in the endpoint -mapper. +- the two catalogs: configure `Cratis:Arc:Introspection` (`Enabled: false` unmaps + them; `RequireAuthentication`/`Roles` protect them; the + `TrustForwardedIdentityHeaders` opt-in is for the header handler only behind a + trusted ingress) — options table in [authentication](references/authentication.md); +- `/.cratis/identity-details/schema`, `/.cratis/users` and `/.cratis/tenants` are + **not** affected by those options. Return fixtures only from the user and tenant + providers, and restrict all three at the trusted ingress. + +Test the discovery endpoints under Production settings, with your fallback policy +enabled. `/.cratis/me` writes the result as JSON **and** as a base64 cookie named `.cratis-identity`, deliberately **not `HttpOnly`** so the frontend can read it @@ -91,46 +120,105 @@ is `Lax`, `Path` is `/`. ⚠️ A non-`HttpOnly` cookie the browser can read is a cookie the user can edit. Treat everything in it as a **display** input. Never let it decide anything the -server has not already decided. +server has not already decided. Today the default is that `/.cratis/me` returns +whatever a presented `.cratis-identity` cookie decodes to *before* it looks at the +authenticated principal, so a client can make it report any identity or roles +(open Arc#2939). Commands and queries authorize on the request principal and are +not affected; a frontend that shows or branches on `/.cratis/me` can be misled. -## Authorization — what Arc actually evaluates +## Authorization — what Arc evaluates -Arc ships exactly three attributes, all in `Cratis.Arc.Authorization`: +Arc's attributes live in `Cratis.Arc.Authorization`: | Attribute | Targets | Notes | | --- | --- | --- | | `[AllowAnonymous]` | class, method | No arguments | -| `[Authorize]` | class, method, repeatable | Settable `Policy`, `Roles` (comma-delimited), `AuthenticationSchemes` | +| `[Authorize]` | class, method, repeatable | `Policy`, `Roles` (comma-delimited), `AuthenticationSchemes` | | `[Roles(params string[] roles)]` | class, method | Derives from `[Authorize]`, joins the roles with commas | There is **no** `[Policy]`, `[Scopes]` or `[Claims]` attribute anywhere in Arc. - -⚠️ **`AuthorizeAttribute.Policy` and `AuthenticationSchemes` are declared but -never read by Arc's evaluator.** Writing `[Authorize(Policy = "…")]` compiles and -enforces nothing. Only `Roles` is evaluated. - -Evaluation, in order: - -1. every `IAnonymousEvaluator` is consulted; the first non-null answer decides - anonymous access; -2. every `IAuthorizationAttributeEvaluator` is consulted for `[Authorize]` and - its `Roles`; -3. no `[Authorize]` at all means **authorized**; with `[Authorize]`, a null or - unauthenticated principal fails, and a non-empty role list passes when the - user is in **any** of them (comma-split and trimmed — OR, not AND); -4. a method with no `[Authorize]` falls back to its declaring type's verdict. - -Both `[Authorize]` and `[AllowAnonymous]` on the same member throws -`AmbiguousAuthorizationLevel`. +Arc's attributes are the portable choice: they are enforced on every Arc host. + +⚠️ **Which version you run decides what is enforced.** Through `22.20.0` (this +skill's old pin was `22.16.0`) only `Roles` is evaluated: `[Authorize(Policy = "…")]` +and `AuthenticationSchemes` enforce nothing, and Microsoft's `[Authorize]` is **not** +honored on model-bound commands and read models, so a command protected only by it +is open. From `22.21.0` Microsoft's `[Authorize]`/`[AllowAnonymous]` are enforced on +the ASP.NET Core host (never on the standalone Core host), and from `22.23.0` named +policies and schemes are evaluated. The rest of this section is `22.41.1` behavior; +see [authorization](references/authorization.md) for the version table. + +At `22.41.1`: + +- **Named policies work.** `[Authorize(Policy = "…")]` requires the policy to + succeed asynchronously. Implement + `IAuthorizationPolicy.IsAuthorized(AuthorizationPolicyContext, CancellationToken)` + and register it with `AddArcAuthorizationPolicy(name)` before `Build()`. + Unknown or duplicate names fail startup; an unresolved policy never grants access. + The context carries `Principal`, `Target`, `Resource` and `ReceivedAt`. + The old synchronous `IAuthorizationEvaluator.IsAuthorized` rejects policy-bearing + declarations. +- **Schemes.** `AuthenticationSchemes` is honored on the ASP.NET Core host and + rejected at startup on the standalone Core host. +- **Composition.** Stacked attributes **AND**; only a role list is OR. A method's + declaration **replaces** its type's — a method-level `[AllowAnonymous]` or + guest policy removes a type-level `[Roles]` for that method. `[Authorize]` plus + `[AllowAnonymous]` on one member throws `AmbiguousAuthorizationLevel`. +- **Guests.** A policy requires an authenticated principal by default. Unauthenticated + callers are evaluated only for policies registered with + `AddArcAuthorizationPolicy(name, evaluatesAnonymous: true)`, or ASP.NET policies + opted in with `AddArcAnonymousAspNetAuthorizationPolicy(name)` (exact, case-sensitive + name; no authenticated-user requirement, no schemes). Roles and schemes always + require authentication. +- **Order.** Policies are awaited **before** `Provide()`, `Handle()` or the query + method; context-value providers and scope `Begin` run before the verdict; + identity and declaration continuity is rechecked around each policy and before + the handler. A change denies. Policy checks gate **admission**; they do not revoke. +- **Baselines.** `IFallbackAuthorizationEvaluator` supplies a baseline for commands + and queries with no explicit declaration, on both hosts, and ASP.NET's + `FallbackPolicy` covers HTTP endpoints and MVC actions. They are separate: + neither covers what the other does, and neither protects an explicitly anonymous + endpoint. +- **Direct calls bypass authorization.** Calling `Handle()` or a query method + directly skips authorization, validation and filters; only the pipeline and + mapped endpoints enforce them. Server-side callers still need a principal + (`ICurrentPrincipalAccessor`, `ISystemExecution`). +- **Results.** A denied command returns `CommandResult.Unauthorized(context.CorrelationId)`, + a query `QueryResult.Unauthorized(...)`; mapped Arc denials are 403, middleware may + answer 401 or another shape. `ARC0011` warns when a `[Roles]` argument is a string literal: use `nameof(.)` so a rename is a compile error rather than a silent -authorization failure — a desynchronized literal either locks the endpoint or -matches a stale role. - -Read [authorization](references/authorization.md) for the default-access -question, which is genuinely different between Arc's two hosting models, and for -the filter and system-execution extension points. +authorization failure. `ARC0019` flags `[AllowAnonymous]` with `[Authorize]`/`[Roles]`, +`ARC0020` flags an ASP.NET Core attribute that is not enforced without Arc's ASP.NET +Core integration, and `ARC0021` flags `AuthenticationSchemes` on the Core host. + +### Observable queries and revocation + +Authorization runs when a subscription is established. Nothing ends a running +stream when a token expires, a session is signed out or a role changes. Implement +`IGuardObservableQueryEmission` (discovered by convention, no registration) to +re-check access on **every emission on every transport** (hub, WebSocket, SSE): +the verdict is `Allow`, `Suppress` (withheld, delta baseline unmoved) or +`DenyAndTerminate` (that subscription only; the client stops reconnecting). The most +restrictive verdict wins, and a guard that throws, or cannot be constructed, fails +closed — the latter denies every observable query in the process. The principal comes +from `ObservableQueryEmissionContext.Principal`, never from ambient accessors or +`IHttpContextAccessor`; the guard is constructed per emission, so keep it cheap and +cache in a singleton. Guards do not protect the HTTP snapshot path. Read +[observable emission guards](references/observable-emission-guards.md). + +### Validators are not an authorization boundary + +A `[ProtectedDecision]` command runs only certified, parameterless validators and +refuses validators with constructor dependencies. A permission or ownership rule +that depends on state does not belong in a validator (or in an `[Unprotected]` +one): put it in `Provide()`/`Handle()` with `DecisionRead` or `IDecisionReads`, +and see the command-validation and Chronicle guidance. + +Read [authorization](references/authorization.md) for the guest rules, evaluation +order, the two baselines, default access under Arc's two hosting models, the +filters, and system-execution. ## Authentication — the extension point @@ -209,7 +297,8 @@ implements as `ICanProvideUsers` and `ICanProvideTenants`. To run locally with an identity, supply the forwarded headers yourself — see [local development](references/local-development.md). -⚠️ Those two endpoints are `AllowAnonymous` and mapped unconditionally. If your +⚠️ Those two endpoints are `AllowAnonymous` and mapped unconditionally, in +Production too, and a fallback policy does not protect them. If your `ICanProvideUsers` implementation returns real users, that list is public. Return development fixtures only, and gate the implementation on a development environment check. @@ -218,9 +307,30 @@ environment check. - Exactly one non-default `IProvideIdentityDetails` implementation exists, and it also implements `IProvideIdentityDetails`. -- No authorization decision depends on `[Authorize(Policy = …)]`. +- The Arc version in use enforces what the code declares: policies and Microsoft's + attributes are not enforced before `22.23.0` and `22.21.0` respectively. +- Every named policy is registered exactly once; a policy meant for guests has the + explicit opt-in, and no declaration mixes a guest policy with roles or schemes. +- A method-level `[AllowAnonymous]` or guest policy is intended to replace its + type's `[Roles]`. - `[Roles]` arguments use `nameof`, so `ARC0011` is silent. - No member carries both `[Authorize]` and `[AllowAnonymous]`. +- A baseline exists on purpose: an `IFallbackAuthorizationEvaluator` for the Arc + pipeline and, on ASP.NET Core, a `FallbackPolicy` for HTTP endpoints and MVC + actions. +- Authorization-sensitive logic is never reached by calling `Handle()` or a query + method directly. +- Observable queries that serve private or revocable data have an emission guard + backed by current session and membership state. +- Permission and ownership rules in `[ProtectedDecision]` commands live in + `Provide()`/`Handle()`, not in validators. +- Discovery endpoints (`/.cratis/commands`, `/.cratis/queries`, + `/.cratis/identity-details/schema`, `/.cratis/users`, `/.cratis/tenants`) are + tested under Production settings; introspection is restricted or disabled where it + must not be public, and the rest are restricted at the ingress. +- `TrustForwardedIdentityHeaders` is set only behind an ingress that authenticates + callers, strips caller-supplied identity headers, writes trusted ones and blocks + direct backend access; custom handlers honor it. - Every rule the frontend enforces also exists on the server. - Nothing trusts the identity cookie or the forwarded headers beyond what the ingress guarantees. @@ -230,7 +340,8 @@ environment check. ## Route near misses -- Rejecting a command's input or state: `cratis-arc-command-validation` scope. +- Rejecting a command's input or state: `cratis-arc-command-validation` scope + (validators are not the place for state-dependent permission rules). - Chronicle tenant namespaces and per-tenant event stores: the Chronicle multi-tenancy guidance. - Building the React page that consumes identity: the Arc React guidance. diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md index 8c17d527..88a3aa9f 100644 --- a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md @@ -1,7 +1,7 @@ # Authentication -Verified against `Cratis.Arc.Core` and `Cratis.Arc` `22.16.0`. Types are in +Verified against `Cratis.Arc.Core` and `Cratis.Arc` `22.41.1`. Types are in `Cratis.Arc.Authentication` and `Cratis.Arc.Identity`. ## The contract @@ -121,8 +121,62 @@ services.AddMicrosoftIdentityPlatformIdentityAuthentication(scheme?); registers the standard ASP.NET Core scheme backed by `MicrosoftIDentityPlatformAuthHandler` (note the capital `D` in the type name), -whose `SchemeName` is `"MicrosoftIdentityPlatform"`. `builder.AddCratis()` calls -this for you. +whose `SchemeName` is `"MicrosoftIdentityPlatform"`. `builder.AddCratis()` (the +`Cratis` package) calls this for you and makes it the **default** authentication +scheme; `AddCratisArc()` from `Cratis.Arc` does not register it. + +⚠️ Today the default is that a plain `builder.AddCratis()` host trusts the unsigned +`x-ms-client-principal*` headers from **any caller that can reach it** — it is safe +only behind the ingress described below (tracked as open Arc#2946). If the host is +not behind such an ingress, do not use this scheme: configure a credential-validating +ASP.NET Core scheme yourself. + +## Forwarded headers, ingress requirements and protected introspection + +The headers are only safe behind an ingress that **authenticates callers, strips +any caller-supplied identity headers, writes its own trusted replacements, and +prevents direct access to the backend**. The same applies to any custom header +such as `X-User-ID`. Adding a bearer validator does not make a separately accepted +forwarded-header mechanism safe. + +Arc applies that rule to one place itself: the protected introspection catalogs +(`/.cratis/commands`, `/.cratis/queries`). By default those are enabled and +**anonymous**. Configure `Cratis:Arc:Introspection`: + +| Option | Default | Effect | +| --- | --- | --- | +| `Enabled` | `true` | `false` leaves both catalog routes unmapped | +| `RequireAuthentication` | `false` | `true` requires an authenticated caller, regardless of the host's default authorization policy | +| `Roles` | `null` | Comma-separated, any one grants access; needs `RequireAuthentication: true` and no empty entries | +| `TrustForwardedIdentityHeaders` | `false` | Accepts identity from the unsigned forwarded headers for the protected catalog; needs `Enabled` and `RequireAuthentication` | + +Today the default is that the catalogs are public, and with +`RequireAuthentication: true` the forwarded headers are **ignored** for them until +you set `TrustForwardedIdentityHeaders: true`: the built-in +`MicrosoftIdentityPlatformAuthenticationHandler` returns +`AuthenticationResult.Anonymous` for a protected catalog endpoint, so the request +gets 401. **Only set the opt-in behind the trusted ingress above.** + +- Startup fails closed for unsafe combinations: `Roles` or the trust option + without the requirements above; on Arc.Core, `RequireAuthentication` with no + handler, or with only the built-in header handler and no trust opt-in; on + ASP.NET Core, a header-trusting scheme reachable from the default scheme or the + catalog's policy schemes without the opt-in, and a reachable scheme with + `ForwardDefaultSelector` when a header handler is registered. +- **Custom handlers.** Arc cannot tell whether your own `IAuthenticationHandler` + reads forwarded headers, and startup accepts any custom handler as + credential-validating. If yours builds an identity from ingress headers, read + `IOptions` and return `AuthenticationResult.Anonymous` when + `TrustForwardedIdentityHeaders` is `false` and + `context.GetEndpointMetadata()?.RequireAuthentication == true`. A subclass of the + ASP.NET Core header handler that overrides `HandleAuthenticateAsync` without + calling the base is your own handler and carries the same obligation. +- These options do **not** change `/.cratis/identity-details/schema`, command and + query invocation, or the user and tenant discovery endpoints. Restrict those at + the ingress. On ASP.NET Core a protected catalog's 401/403 is the configured + scheme's challenge, which a cookie scheme may turn into a redirect. + +Discovery exposure as a whole is tracked in open Arc#2834. ## What is not here diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md index e0484cc2..3f6621e8 100644 --- a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md @@ -1,95 +1,280 @@ # Authorization -Verified against `Cratis.Arc.Core` and `Cratis.Arc` `22.16.0`. Everything below -is in `Cratis.Arc.Authorization` unless stated. +Verified against `Cratis.Arc.Core` and `Cratis.Arc` `22.41.1`. Everything below +describes that version and is in `Cratis.Arc.Authorization` unless stated. -## Default access differs by hosting model +## Versions differ — check the one you run -There is **no option anywhere in Arc** that sets a default authentication or -authorization requirement. `ArcOptions` has no such switch. What you get depends -on which pipeline is running. +Authorization changed a lot between the version this skill used to pin +(`22.16.0`) and `22.41.1`. Advice that is right for one range is wrong for +another. -### Self-hosted `ArcApplication` (Arc.Core) +| Version | Behavior | +| --- | --- | +| through `22.20.0` (includes `22.16.0`) | Only Arc's own attributes count, and only `Roles` is evaluated: `Policy` and `AuthenticationSchemes` are read by nothing, so `[Authorize(Policy = "…")]` enforces nothing. Microsoft's `[Authorize]`/`[AllowAnonymous]` are **not** enforced on model-bound commands and read models (Arc's ASP.NET evaluators bind to Arc's same-named attributes), so a command protected only with Microsoft's attribute is open to every caller. Only the first attribute or evaluator that answers is used. | +| `22.21.0` | Microsoft's `[Authorize]`/`[AllowAnonymous]` are enforced on the ASP.NET Core host. Every stacked requirement applies (AND). Anonymous plus restricted on one member is rejected as ambiguous. Roles are still the only requirement evaluated. | +| `22.23.0` | Named policies and `AuthenticationSchemes` are evaluated (`IAuthorizationPolicy`, `AddArcAuthorizationPolicy`, ASP.NET Core policies). | +| `22.26.0` | `Cratis:Arc:Introspection` options. | +| `22.30.0` | `IFallbackAuthorizationEvaluator` and `AuthorizationPolicyContext.ReceivedAt`. | +| `22.31.0` | Opt-in policy evaluation for unauthenticated callers. | +| `22.39.0` | Protected decisions arrive; the validator boundary at the end of this page is verified at `22.41.1`. | -`AuthenticationMiddleware` runs per request: +If you are on a version before `22.21.0`, either upgrade, or use only Arc's +attributes with roles and treat everything else as unenforced. Do not write a +policy-protected endpoint on a version before `22.23.0`. The rest of this page +is `22.41.1` behavior. -- **No `IAuthenticationHandler` registered at all → every endpoint is anonymous.** - The middleware returns immediately. -- With at least one handler registered, an endpoint marked `AllowAnonymous` - passes and everything else requires an authenticated principal — otherwise the - response is **401** with a body of `Unauthorized`. +## Attributes -`AllowsAnonymousAccess` defaults to **false**: `IsAnonymousAllowed()` on a -top-level type with no attributes has no declaring type to fall back to and -returns false. So under this pipeline commands and queries are -authenticated-by-default *once any handler exists*. +| Attribute | Notes | +| --- | --- | +| `[AllowAnonymous]` | Arc's: class or method, no arguments | +| `[Authorize]` | Arc's: class or method, repeatable; settable `Policy`, `Roles` (comma-delimited, OR), `AuthenticationSchemes` | +| `[Roles(params string[] roles)]` | Arc's: derives from Arc's `[Authorize]` and joins the roles with commas | -### ASP.NET Core hosting (the `Arc` package) +There is **no** `[Policy]`, `[Scopes]` or `[Claims]` attribute in Arc. + +With `Cratis.Arc` (ASP.NET Core host), Arc's pipeline **also enforces Microsoft's** +`[Authorize]` and `[AllowAnonymous]` on model-bound commands and read models, +including their policies and schemes, over mapped HTTP and hub subscriptions +(`22.21.0` and later). The two families can be mixed. Arc's own attributes are +the portable choice: they are enforced on every Arc host, whereas on the +standalone Core host nothing reads Microsoft's. `RolesAttribute` derives from +**Arc's** `AuthorizeAttribute`, never Microsoft's. + +Analyzers: `ARC0011` (use `nameof` in `[Roles]`), `ARC0019` (`[AllowAnonymous]` +conflicts with `[Authorize]`/`[Roles]` on one declaration), `ARC0020` (an ASP.NET +Core attribute is not enforced without Arc's ASP.NET Core integration), `ARC0021` +(`AuthenticationSchemes` needs the ASP.NET Core host). Not every older NuGet +version contains every rule. + +## Composition and precedence -The endpoint mapper only ever calls `AllowAnonymous()`. It **never** calls -`RequireAuthorization()`, and nothing else in Arc does either. Endpoints are -therefore **anonymous by default** unless the application configures its own -fallback policy in ASP.NET Core. +- **Stacked requirements AND.** Every attribute on a declaration applies. A role + list is the only OR: the caller needs **any one** role of a single list. + Policies and roles are never OR'd with each other. +- **A method replaces its type.** A method's declaration replaces its type's + declaration; it does not combine with it, whichever attribute family each + comes from. A method-level `[AllowAnonymous]`, or a guest-allowed policy on a + method, removes a type-level `[Roles("Admin")]` protection from that method. +- **Anonymous plus restricted is ambiguous.** `[AllowAnonymous]` with `[Authorize]` + or `[Roles]` on one member throws `AmbiguousAuthorizationLevel`, in either + attribute family (Microsoft's is read with `inherit: true`). +- **No attribute at all** means no authentication or role requirement, unless a + fallback evaluator supplies a baseline (below). -Decide this deliberately for your host. Do not assume "Arc protects it". +## Named policies -## Role evaluation +```csharp +public class ActiveSubscription : IAuthorizationPolicy +{ + public ValueTask IsAuthorized(AuthorizationPolicyContext context, CancellationToken cancellationToken) => + ValueTask.FromResult(context.Principal.HasClaim("subscription", "active")); +} + +builder.Services.AddArcAuthorizationPolicy("ActiveSubscription"); // before Build() +``` -`AuthorizationEvaluator`: +```csharp +[Authorize(Policy = "ActiveSubscription")] +[Command] +public record (...) +``` -1. every `IAnonymousEvaluator` is asked; the first non-null answer wins — `true` - authorizes immediately, `false` stops asking and moves on; -2. every `IAuthorizationAttributeEvaluator` is asked for `[Authorize]` and its - `Roles`; the first that reports one wins; -3. no `[Authorize]` → **authorized**; -4. `[Authorize]` with a null or unauthenticated principal → denied; -5. `[Authorize]` with roles → authorized when the user is in **any** of them. - The string is comma-split and trimmed. It is OR, never AND; -6. a `MethodInfo` with no `[Authorize]` falls back to its declaring type. +- `IAuthorizationPolicy.IsAuthorized(AuthorizationPolicyContext, CancellationToken)` + returns `ValueTask`. The policy is registered **scoped** and resolves + from the command's or query's executing scope, so it may depend on scoped + services. +- `AuthorizationPolicyContext` is `(ClaimsPrincipal Principal, MemberInfo Target, object Resource)` + plus `ReceivedAt`. `Target` is the command type or query method; `Resource` is + the executing `CommandContext` or `QueryContext`. +- `ReceivedAt` is **Arc receipt time**, captured once at transport dispatch (or + direct pipeline entry, or per hub subscribe operation) and fixed across + delayed or repeated evaluation. Use it for time-based admission instead of + reading the clock after an asynchronous lookup. It is not network arrival + time. A default value means no receipt: deny. +- Register every policy before the host starts. An unknown or duplicate name + fails startup, and an unresolved policy at runtime never grants access. Register + a name natively **or** in ASP.NET Core, not both. +- On the ASP.NET Core host, Arc also evaluates policies registered with + ASP.NET Core (`AddAuthorizationBuilder().AddPolicy(...)`), referenced by either + attribute family. The handlers receive Arc's `CommandContext`/`QueryContext` + as the resource, not an MVC resource. +- The old synchronous `IAuthorizationEvaluator.IsAuthorized(Type|MethodInfo)` + **rejects policy-bearing declarations** (`AsynchronousAuthorizationRequired`). + Use the command or query pipeline. +- The standalone Core host fails startup on `AuthenticationSchemes`. The ASP.NET + Core host authenticates each named scheme, combines the identities of the ones + that succeed, and denies when none does; a request that selects schemes runs + in a fresh execution scope. `ICurrentPrincipalAccessor` then exposes the + selected identity. -`[Authorize]` and `[AllowAnonymous]` on the same member throws -`AmbiguousAuthorizationLevel` from `AnonymousEvaluator` and from -`AllowAnonymousExtensions.IsAnonymousAllowed`. +## Guests and anonymous policies -⚠️ The ASP.NET Core-flavoured evaluators (`AspNetAnonymousEvaluator`, -`AspNetAuthorizationAttributeEvaluator`) are declared inside -`namespace Cratis.Arc.Authorization` and do not import -`Microsoft.AspNetCore.Authorization`, so the attribute names in them bind to -**Arc's own** attributes. Do not rely on ASP.NET Core's `[Authorize]` being -honoured by them. `AspNetAnonymousEvaluator` also does not throw on the -both-attributes case; it prefers `AllowAnonymous`. +By default **a policy requires an authenticated principal**, even when the policy +body would allow an anonymous caller. A guest is evaluated only after an explicit +opt-in: -## The filters +- Arc policy: `AddArcAuthorizationPolicy(name, evaluatesAnonymous: true)`. The + policy receives an empty unauthenticated `ClaimsPrincipal` and decides itself. +- ASP.NET Core policy: register it as usual **and** call + `AddArcAnonymousAspNetAuthorizationPolicy(name)`. The policy must not call + `RequireAuthenticatedUser()` and must not select authentication schemes. The + opt-in name must equal the declaration's name exactly, **case-sensitive**. + The standalone Core host cannot use this opt-in. +- Startup rejects a missing, ambiguous, native, authentication-required or + scheme-selected opt-in. +- Only a declaration made **entirely** of opted-in policies, with no roles and no + schemes, can evaluate guests. Roles and schemes always require authentication. + +⚠️ The opt-in governs Arc's pipeline verdict only. Today the HTTP layer can reject +a guest before the policy runs: a Core host with any authentication handler, or an +ASP.NET Core `FallbackPolicy` that requires authentication, returns 401 first +(tracked in Cratis/Arc#2948). Test guest endpoints through the real HTTP host. + +⚠️ Because a method replaces its type, a method-level guest policy on a read model +with a type-level `[Roles("Admin")]` is reachable by guests when the policy +allows them. The type-level role no longer protects that method. + +## Evaluation order + +1. Anonymous evaluators and attribute evaluators resolve the declaration: method + first, then its type, then the fallback baseline. Conflicts throw + `AmbiguousAuthorizationLevel`. +2. On the ASP.NET Core host, named schemes are authenticated and the selected + identity chosen. +3. Command context-value providers and the execution-scope `Begin` hooks run + next, **before the policy verdict**, so they can run for a caller the policy + later denies. They must not perform irreversible effects. +4. Authentication and roles are checked, and every policy is **awaited**, one at a + time, **before** `Provide()`, `Handle()` or the query method runs. +5. Identity and declaration continuity is rechecked before and after each policy + and again immediately before the handler or query method; a change denies. +6. Only then does `Provide()`/`Handle()`/the query method run. + +⚠️ Policy checks gate **admission**. They do not revoke: a running observable +stream or a deferred enumeration is not rechecked, and a change in an external +permission store after admission is not detected. See +[observable emission guards](observable-emission-guards.md). + +## Baselines: two separate mechanisms + +**`IFallbackAuthorizationEvaluator`** supplies baseline requirements for commands +and queries that have **no explicit declaration**, on both hosts. Implement it on +a public class; it is discovered by convention, do not register it. + +```csharp +public class MembersByDefault : IFallbackAuthorizationEvaluator +{ + public IEnumerable GetAuthorizationRequirements(Type type) => + [AuthorizationRequirement.FromRoles("Member")]; + + public IEnumerable GetAuthorizationRequirements(MethodInfo method) => []; +} +``` + +- Commands and type-targeted queries consult only the `Type` overload; the + `MethodInfo` overload applies only to query methods. Put a baseline for all + commands in the `Type` overload. +- A query method's explicit declaration, or its type's, replaces the baseline. + With neither, the method and type baselines **all** apply, across evaluators. +- `[AllowAnonymous]` still wins; a method or type `[Authorize]` replaces the + baseline. It returns requirements, not verdicts: use a policy or a filter for + resource-dependent rules. +- `IAuthorizationAttributeEvaluator` reports only **explicit** declarations from + another attribute family; it never becomes a baseline. + +**ASP.NET Core `FallbackPolicy`** covers HTTP endpoints and MVC actions without +authorization metadata. It does **not** supply Arc's pipeline baseline, and +`IFallbackAuthorizationEvaluator` does not cover direct HTTP GET and WebSocket +requests to **controller-based** queries or controller-based commands (those are +MVC actions). Configure both: the evaluator for the Arc pipeline, `FallbackPolicy` +for MVC. Neither protects an endpoint that is mapped explicitly anonymous (see the +discovery endpoints in the skill). + +## Default access by hosting model + +There is no `ArcOptions` switch for a default requirement. What you get depends +on the pipeline. + +### Self-hosted `ArcApplication` (Arc.Core) + +The authentication middleware runs per request: + +- **No `IAuthenticationHandler` registered → the middleware lets the request + through** (every endpoint is effectively anonymous), except an endpoint whose + metadata sets `RequireAuthentication`, which throws + `AuthenticationRequiredWithoutHandlers` instead. +- With at least one handler, an endpoint marked `AllowAnonymous` passes and every + other endpoint requires an authenticated principal, or the response is **401** + with body `Unauthorized` (**403** when a metadata role is missing). Command and + query endpoints are marked anonymous only through Arc's `[AllowAnonymous]` + (`IsAnonymousAllowed`, falling back to the declaring type). + +Pipeline authorization (roles, policies, fallback baseline) is a second, +separate check. + +### ASP.NET Core hosting (the `Arc` package) + +Arc's endpoint mapper marks endpoints anonymous with `AllowAnonymous()`, and it +**does** call `RequireAuthorization(...)` when an endpoint's metadata requires +authentication or roles (for example the protected introspection catalog). +Command and query endpoints are otherwise not protected by the mapper, so +**without a `FallbackPolicy`, ASP.NET Core endpoints are anonymous**, and what +remains is Arc's pipeline authorization on the declared attributes and any +`IFallbackAuthorizationEvaluator`. Decide this deliberately; do not assume "Arc +protects it". + +## The filters and results | Interface | Namespace | Shape | | --- | --- | --- | | `IAuthorizationCommandFilter` | `Cratis.Arc.Commands` | Empty marker over `ICommandFilter` (`Task OnExecution(CommandContext)`) | | `IAuthorizationQueryFilter` | `Cratis.Arc.Queries` | Empty marker over `IQueryFilter` (`Task OnPerform(QueryContext)`) | -Both are found by discovery — implement one on a public class and it is picked -up. The marker is what guarantees ordering: the filter chains sort -authorization filters first with a stable sort, so a validation failure can never -short-circuit the chain before the authorization verdict is recorded. +Both are found by discovery. The marker guarantees ordering: authorization filters +sort first (stable), so a validation failure never short-circuits before the +authorization verdict. + +The built-in filters run roles, policies, schemes and the fallback baseline in the +same pipeline. The command filter returns `CommandResult.Unauthorized(context.CorrelationId)`; +the query filter returns `QueryResult.Unauthorized(context.CorrelationId)`, and +returns **success** when no performer is found for the name. A custom filter that +denies uses the same results — not `CommandResult.Error(...)`, which describes an +exception. + +Mapped Arc denials set `IsAuthorized = false` and return **403**. Middleware may +reject earlier with **401**, and ASP.NET Core's own challenge/forbid can return a +different shape (a cookie scheme may redirect). Do not infer which layer ran from +the status alone. + +⚠️ `AuthorizationResult` — `record (bool IsAuthorized, string? FailureReason)` with +`Success` and `Failure(reason)` — is **not** what either filter returns and not +what `IAuthorizationEvaluator` produces. It is a standalone type, usable as a +command `Provide()` short-circuit value. A handler returning +`ValidationResult.Error(...)` is **validation**, not authorization. -Built-in implementations: the command `AuthorizationFilter` returns -`CommandResult.Unauthorized(...)` from `IAuthorizationEvaluator`; the query -`AuthorizationFilter` asks the query performer, and returns **success** when no -performer is found for the name. +## Direct calls bypass authorization -⚠️ `AuthorizationResult` — `record (bool IsAuthorized, string? FailureReason)` -with `Success` and `Failure(reason)` — is **not** what either filter returns and -not what `IAuthorizationEvaluator` produces (that returns a plain `bool`). It is -a standalone type, usable as a command `Provide()` short-circuit value. Do not -describe it as the filter contract. +Attributes do not intercept ordinary C# calls. `new (...).Handle()` and a +static query method called directly skip authorization, validation, filters and +result handling. Use a mapped endpoint or the command/query pipeline when those +guarantees matter. Server-side callers must still establish a principal (below); +being in-process does not prove permission. -## Executing as a system actor +## The principal and system actors -Command authorization reads the principal from the current HTTP request. A -reactor, hosted service, or one command orchestrating another has no request, so -any command carrying `[Authorize]` or `[Roles]` is **denied**. +`ICurrentPrincipalAccessor.Current` is a nullable `ClaimsPrincipal`, independent +of transport. During HTTP it is the request principal; outside HTTP it can be the +principal established by a server-side scope. Read claims from it, not from the +identity cookie, and not through `IHttpContextAccessor` in code that must also run +on hub emissions. -`ISystemExecution` is the way out: +Command authorization reads the principal from the current request. A reactor, +hosted service, or one command orchestrating another has no request, so any +command carrying `[Authorize]` or `[Roles]` is **denied**. `ISystemExecution` is +the way out: ```csharp using var scope = systemExecution.AsSystem(nameof(.)); @@ -102,12 +287,19 @@ await pipeline.Execute(new ()); - Disposing restores the previous context. `SystemPrincipal.AuthenticationType` is `"System"` and `SystemPrincipal.Subject` -is `"[System]"`, which is what appears in audit trails. +is `"[System]"`, which is what appears in audit trails. The established principal +is consulted **only** when there is no HTTP request context, so it can never +influence an HTTP-origin command. Keep the scope as narrow as the work: a broad +`AsSystem` around a whole hosted service makes every command it touches +privileged. -The established principal is consulted **only** when there is no HTTP request -context, so it can never influence the authorization of an HTTP-origin command. -`CurrentPrincipalAccessor` implements that: a request principal always wins over -the ambient override. +## Protected decisions and validators -Keep the scope as narrow as the work. A broad `AsSystem` around a whole hosted -service turns every command it touches into a privileged one. +A `[ProtectedDecision]` command (`Cratis.Arc.Chronicle.ReadModels`) runs only +certified, parameterless validators and **refuses** a validator whose constructor +takes dependencies (a read model, `DecisionRead`, `IDecisionReads`, any +service). So an authorization or ownership rule put in such a validator — or in an +`[Unprotected]` command's validator — is **not decision-safe**. Put +state-dependent permission and ownership rules in `Provide()` or `Handle()` using +`DecisionRead` or `IDecisionReads`, and route to the validation and Chronicle +guidance. Validators that read decision state are tracked in Arc#2831. diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md index 867e81cc..9a3c685a 100644 --- a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md @@ -1,7 +1,7 @@ # Frontend identity -Verified against `@cratis/arc` and `@cratis/arc.react` `22.16.0`. +Verified against `@cratis/arc` and `@cratis/arc.react` `22.41.1`. ## `@cratis/arc/identity` @@ -52,6 +52,11 @@ interface IIdentityContext extends IIdentity { It seeds `isLoading: true` and fetches on mount. The root `` component takes the same `detailsType` prop and renders this provider for you. +When a refresh finds that an identity which was set is now unset (the server-side +session ended), the React `IdentityProvider` reloads the page with a full +top-level navigation rather than leaving the tab signed out and stuck. Do not rely +on a signed-out render surviving a refresh. + `useIdentity` has two overloads: ```ts diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md index 2b778cbc..6ba368e0 100644 --- a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md @@ -1,7 +1,7 @@ # Running with an identity locally -Verified against `Cratis.Arc.Core` and `Cratis.Arc` `22.16.0`. +Verified against `Cratis.Arc.Core` and `Cratis.Arc` `22.41.1`. ## What Arc does not give you @@ -30,8 +30,11 @@ public interface ICanProvideUsers { Task> Provide(); } ``` ⚠️ Both are mapped `AllowAnonymous` and **unconditionally** — there is no -environment check in the mapper. Whatever your implementations return is public -to anyone who can reach the application. Register them only for a development +environment check in the mapper, so they are anonymous in Production too, and an +ASP.NET `FallbackPolicy` does not protect them. Without providers they return +empty arrays. Whatever your implementations return is public to anyone who can +reach the application. The `Cratis:Arc:Introspection` options do not affect them; +restrict them at the ingress (see the discovery note in the skill). Register them only for a development environment, and return fixtures rather than production users or tenants. ## Supplying an identity locally diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/observable-emission-guards.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/observable-emission-guards.md new file mode 100644 index 00000000..6f1a1a83 --- /dev/null +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/observable-emission-guards.md @@ -0,0 +1,105 @@ + +# Observable query emission guards + +Verified against `Cratis.Arc.Core` `22.41.1` (`Cratis.Arc.Queries`). + +Authorization for an observable query runs **once, when the subscription is +established**. It decides whether the caller may obtain the live stream. It does +**not** end the stream when a token expires, a session is signed out, or a role or +membership changes; keep-alive pings keep the connection open and the client +reconnects on its own. An **emission guard** closes that gap. + +## The opt-in + +Implement `IGuardObservableQueryEmission`. Guards are discovered by convention: +no registration, no configuration. With no guard installed, emissions take their +existing path. + +```csharp +public class SessionMustStillBeActive(ISessions sessions) : IGuardObservableQueryEmission +{ + public async Task Guard(ObservableQueryEmissionContext context) + { + var sessionId = context.Principal?.FindFirst("sid")?.Value; + if (sessionId is null) return ObservableQueryEmissionVerdict.DenyAndTerminate; + + return await sessions.IsActive(sessionId) + ? ObservableQueryEmissionVerdict.Allow + : ObservableQueryEmissionVerdict.DenyAndTerminate; + } +} +``` + +`ISessions` is the application's own store of authoritative session state — not an +Arc API, and never an always-true stub. + +It runs on **every emission** of **every subscription**, on **every transport**: +the multiplexed hub, WebSocket and SSE. + +## Verdicts + +| Verdict | Effect | +| --- | --- | +| `Allow` | The emission is written unchanged. | +| `Suppress` | This emission is withheld; the subscription stays live. It does **not** move the delta baseline, so the next `ChangeSet` is computed against what the client last actually received. | +| `DenyAndTerminate` | Nothing is written and **that subscription only** is torn down (siblings on the same connection keep streaming). The client gets `Unauthorized`, latches the denial and stops reconnecting. | + +Several guards: every guard is asked, the **most restrictive** verdict wins +(`DenyAndTerminate` over `Suppress` over `Allow`), and the first +`DenyAndTerminate` short-circuits the rest. + +## The context + +`ObservableQueryEmissionContext` carries `QueryName`, `Arguments`, +`SubscriptionScope`, `Principal`, `CorrelationId`, `IsFirstEmission`, +`CancellationToken` and the `ServiceProvider` to resolve from. Each guard gets its +own copy of the scope and arguments. + +- The principal is passed **explicitly** in `Principal`. Emissions arrive on the + producer's thread where the request's ambient context does not flow, so do not + use ambient accessors, and on **hub** emissions do not use `IHttpContextAccessor`: + there is no native `HttpContext` after admission. +- On a **WebSocket** the identity is frozen at the handshake and never refreshes. + The guard must look it up against your source of truth (session store, revocation + list, token introspection), not re-read it. +- `QueryContext.SubscriptionScope` lets a **query filter** record what it decided + (for example the organization). Arc captures it after the filters succeed and + **before** the query executes, and hands each emission a fresh copy; a guard can + compare it to current membership. It must serialize with Arc's + `JsonSerializerOptions` (small, primitive values; no live services or caches), + or the query fails as `InvalidSubscriptionScope`. Setting it does not filter the + data: the query must select its data with the same value. **Controller-based** + observable queries do not run `IQueryFilters`, so their guards always receive + `null`. + +## Fail closed, and its blast radius + +A guard that **throws** is treated as `DenyAndTerminate` and logged at `Error`. +The aggregator is a process-wide singleton over every discovered guard type, so a +guard that **cannot be constructed** (an open generic, or a constructor the +subscription scope cannot resolve) denies **every** observable query in the +process on every transport, with only an `Error` log as the symptom. Cover a new +guard with a spec that runs its real constructor. A cancellation of the +subscription's own token while a guard runs ends the subscription without logging +a failure. + +## Cost + +- The guard is **constructed per emission** and never disposed, because Arc's + `IFoo → Foo` convention does not match a guard named for what it decides. Keep + the constructor trivial and put caches in an injected **singleton**, not in a + field on the guard. +- Prefer cached state, a local revocation list or a short-TTL lookup to a network + round trip per emission. +- Constructor dependencies come from the subscription's own scope, so a scoped + session store is safe to inject. + +## What a guard does not protect + +- The HTTP **snapshot** path (`ObservableQueryHttp`, including + `waitForFirstResult=true`) is not an emission guard path. Apply subscription-time + authorization and safe data selection to every query. +- A guard is not a substitute for the subscription-time declaration: the + `[Authorize]`/`[Roles]`/policy on the read model still decides who may subscribe. +- Interceptor masking is not an access control; the producer must never yield + rows or fields the caller may not receive. diff --git a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md index 05686e49..49a17642 100644 --- a/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md +++ b/.cratis/ai/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md @@ -1,7 +1,7 @@ # Tenancy -Verified against `Cratis.Arc.Core` `22.16.0`. Types are in `Cratis.Arc.Tenancy`. +Verified against `Cratis.Arc.Core` `22.41.1`. Types are in `Cratis.Arc.Tenancy`. Arc tenancy is **request-scoped tenant resolution**. It answers "which tenant is this request for". It is not Chronicle's tenant namespace isolation, and it does @@ -73,12 +73,19 @@ be refused. `string.IsNullOrEmpty(tenantId)` never fires for an unresolved tenant. Compare against `TenantId.NotSet`. +`ITenantScope.Begin(TenantId)` selects a tenant explicitly for the current +asynchronous flow until the returned scope is disposed, and takes precedence over +the configured resolver. It rejects a null or blank tenant, and it does **not** +authorize access to that tenant. Resolve tenant-scoped services in a DI scope +created after beginning it. + ## Development listing `ICanProvideTenants` (`Task> Provide()`) feeds `GET /.cratis/tenants`, where `Tenant` is `record (TenantId Id, TenantName Name)`. -The endpoint is `AllowAnonymous` and mapped unconditionally, so an implementation -that returns real tenants publishes them. Return development fixtures only. +The endpoint is `AllowAnonymous` and mapped unconditionally — also in Production, +and an ASP.NET fallback policy does not protect it — so an implementation that +returns real tenants publishes them. Return development fixtures only. ## Where the boundary is diff --git a/.cratis/ai/skills/cratis-chronicle-cli-operations/SKILL.md b/.cratis/ai/skills/cratis-chronicle-cli-operations/SKILL.md index b4dc199d..ef2d2484 100644 --- a/.cratis/ai/skills/cratis-chronicle-cli-operations/SKILL.md +++ b/.cratis/ai/skills/cratis-chronicle-cli-operations/SKILL.md @@ -135,8 +135,12 @@ list|show`, `failed-partitions list|show`, `projections list|show`, from the outside. - **Fix the cause before replaying.** Replaying into an unfixed handler fails the same way and buries the original error under a newer one. -- **A quarantined observer does not resume by itself.** Clearing the quarantine - is an explicit operation, and it is the last step, not the first. +- **A quarantined observer does not resume by itself.** A quarantine ends when + an operator clears it, or when the observer is subscribed again — for an + application observer, when the client reconnects, for example after a + redeploy. Clearing the quarantine is an explicit operation and the last step, + not the first. A redeploy that restarts the client can also end the quarantine + as a side effect, so check the observer's state before and after a redeploy. ## Before you change anything diff --git a/.cratis/ai/skills/cratis-chronicle-reactor/SKILL.md b/.cratis/ai/skills/cratis-chronicle-reactor/SKILL.md index ca9702ac..ffddbb6f 100644 --- a/.cratis/ai/skills/cratis-chronicle-reactor/SKILL.md +++ b/.cratis/ai/skills/cratis-chronicle-reactor/SKILL.md @@ -314,8 +314,10 @@ If a handler throws, or a returned side-effect event fails to append — a constraint violation, a concurrency violation, or an error — the failing event-source partition **pauses** until the cause is resolved. Repeated failures can **quarantine** the observer, which stops retries and suppresses automatic -recovery. **A quarantined observer does not resume on reconnect**; an operator -must clear the quarantine explicitly. +recovery. **A quarantine ends when an operator clears it +(`ClearObserverQuarantine()`), or when the observer is subscribed again** — for an +application observer that is when the client connects again, for example after a +redeploy; Kernel-owned observers are subscribed again when the Kernel starts. Do not throw to reject a malformed inbound event. A reactor is not a data-quality gate; invalid payloads belong at the command or append site. When a malformed