diff --git a/CHANGELOG.md b/CHANGELOG.md index fed0e49..850845e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to Shelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.1.0] — 2026-07-19 + +Access control for restricted documentation, a much richer analytics dashboard, +and a unified app shell. Backwards-compatible: existing products stay public and +nothing changes until you mark a product restricted. + +### Added + +- **Restricted products & access control** — a product can be marked *restricted*: it is then hidden from anyone without a read grant and returns `404` on unauthorized access (no existence leak); anonymous visitors are sent to login, admins see everything. Orthogonal to visibility, so a product can be preview + restricted. +- **Permission groups** — groups grant read access to restricted products and, optionally, Shelf adminship. Membership is either an explicit email list (stageable before a user's first login) or **auto-computed** from a sandboxed JsEval predicate over the user's login claims (e.g. `user.email.endsWith('@cocoar.dev')`), recomputed at login / on save / on demand. Managed under Administration → Groups, with a script dry-run. +- **Product-side access grants** — assign who may read a restricted product directly on the product (Access tab): both **groups and individual users** are principals and can be granted. Users are keyed by email so access can be staged before their first login. +- **Detailed analytics dashboard** — an interactive **world map** of visitor cities (bubbles by volume), country and city breakdowns with **flags and full names**, browser / OS / language / referrer breakdowns, per-product filtering and an IP-exclude filter. All aggregation happens server-side. +- **Groups & principals API** — `GET/POST/PUT/DELETE /_api/groups`, `POST /_api/groups/recalculate`, `POST /_api/groups/test-script`, `GET /_api/principals`. + +### Changed + +- **Unified app shell** — the public landing page and the admin UI now share one shell: the same header everywhere (with dark/light toggle), the admin sidebar for logged-in admins on every page, and dark mode working on the landing page too. +- **Analytics summary** now returns cities, geolocated map points, and browser/OS/language/referrer breakdowns; the access log stores visitor lat/lng and shows country flags. +- Groups live under Administration alongside Users. + +### Security + +- **Product writes via the admin cookie now require admin.** Previously any authenticated (JIT-provisioned) user could create, update or delete products through the cookie path; this is now gated to admins. The CI/CD Bearer API-key path is unchanged. +- Admin can additionally be granted through an `IsAdminGroup` permission group (in addition to the `shelf:admin` token permission and the `Modgud.Admins` allowlist). + ## [2.0.0] — 2026-07-18 Shelf 2.0 turns the standalone file-only server into a modgud-federated, diff --git a/CLAUDE.md b/CLAUDE.md index 46fc560..b046789 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,28 @@ authorization-code flow (citydiary pattern, code + PKCE): - API keys for CI/CD (`ApiKeyFilter`): per-product key → UI-managed master key (`ShelfSettings` doc) → config/env bootstrap key. Keys are readable for admins (deliberate: docs hosting, not an IdP). +- **Product writes require admin** on the cookie path (`ApiKeyFilter` → `IAccessResolver`); + the Bearer API-key path is unchanged (machine track). + +### Access control v2 (restricted products, groups, principals) + +- `ProductConfig.Restricted` (orthogonal to `Visibility`) hides a product from anyone without a + read grant; `DocsRoutingMiddleware` gates it — authed-no-grant → **404** (no existence leak), + anonymous HTML → login redirect, anonymous asset → 401. Products API / landing / `llms.txt` + filter restricted products out for non-grantees. +- **Groups** (`Group` doc) are permission carriers: `MemberEmails` (explicit, stageable pre-login) + and/or `MembershipMode.Auto` with a **JsEval** predicate over the persisted login **claims + snapshot** (`UserDocument.Claims/Permissions`, refreshed each login). Auto-membership is + materialized (`AutoMemberUserIds`) by `IGroupMembershipRecalculator` at login / group save / + manual. `IsAdminGroup` grants adminship. +- Grants live **on the product**: `ProductConfig.ReadPrincipals` (a `PrincipalRef` list). Both + `Group` and `UserDocument` implement `IPrincipal`, so groups and individual users (by email) + are assignable. `IAccessResolver` (per-request, memoized) resolves `{isAdmin, groupIds, email}` + and `CanRead(product)` — no JsEval in the hot path. +- **Admin** = `shelf:admin` token permission OR `Modgud.Admins` allowlist OR an `IsAdminGroup` + group (via `IAccessResolver.IsAdminAsync`, wired into the `"Admin"` policy, `ApiKeyFilter`, `/auth/me`). +- JsEval: `Cocoar.JsEval.Engine` (copy+adapt from timetodo, in-memory — no event sourcing / SQL + translation); predicates are sandboxed and **fail-closed**. ## Configuration @@ -63,32 +85,46 @@ subdomain (branded login, e.g. `https://shelf.auth.cocoar.dev`), `ApiKey` (boots ## API surface (`/_api`) -- Public: `GET /products`, `GET /products/{p}`, `GET /products/{p}/versions`, `GET /shelf-config` -- Cookie or Bearer key: product CRUD, version upload/delete (`ApiKeyFilter`) -- Auth: `POST /auth/otp/request|verify`, `POST /auth/logout`, `GET /auth/me` +- Public read: `GET /products`, `GET /products/{p}`, `GET /products/{p}/versions`, `GET /shelf-config` + (restricted products filtered out / 404 for non-grantees, per request via `IAccessResolver`) +- Product CRUD, version upload/delete (`ApiKeyFilter`) — Bearer key, or **admin** cookie +- Auth: login is the server route `GET /login` (OIDC challenge); `GET /logout`, `GET /_api/auth/me` - Admin policy: `/settings/*` (master key), `/products/{p}/api-key` (reveal), `/users/*`, - `/analytics/*` (visits, summary, GeoIP status/download) + `/analytics/*` (visits+summary with `product`/`from`/`to`/`excludeIps`, GeoIP status/download), + `/groups/*` (CRUD, `/recalculate`, `/test-script` dry-run), `/principals` (assignable groups+users) ## Key Files Backend (`src/Cocoar.Shelf/`): - `Program.cs` — startup, Marten schema, auth wiring, middleware pipeline - `ShelfOptions.cs` — all configuration (incl. `ModgudOptions`) -- `Identity/` — ModgudUserProvisioning, ModgudClaimsTransformation, +- `Identity/` — ModgudUserProvisioning, ModgudClaimsTransformation, ClaimsSnapshot, RbacCookiePreservation, AdminCheck, MartenUserStore +- `Models/` — ProductConfig (+`Restricted`, `ReadPrincipals`), Group, Principal + (`IPrincipal`/`PrincipalRef`), UserDocument (+claims snapshot), AccessLogEntry (+lat/lng) - `Endpoints/` — ApiEndpoints (products/versions), AuthEndpoints, SettingsEndpoints, - UserEndpoints, AnalyticsEndpoints, TestAuthEndpoints, ApiKeyFilter + UserEndpoints, AnalyticsEndpoints, GroupEndpoints, PrincipalEndpoints, TestAuthEndpoints, ApiKeyFilter - `Middleware/DocsRoutingMiddleware.cs` — docs routing, rewriting, access-log recording - (HTML GETs only) + (HTML GETs only), restricted-product gating - `Services/` — ManifestService, UploadService, SettingsService, GeoIpService, AccessLogChannel/PersistenceService, MartenProductConfigService + migration, - BasePathDetector/Rewriter + MartenGroupService, BasePathDetector/Rewriter +- `Services/Access/` — IAccessResolver, IGroupMembershipEvaluator (JsEval), + IGroupMembershipRecalculator, ILoginAccessProcessor Frontend (`src/Cocoar.Shelf.Client/src/`): - Routed modals via `@cocoar/vue-fragment-parser` (`route.meta.routedFragments`, URL-hash `#create` / `#`; fixed height via `overlayOptions`); shared shell `components/ModalLayout.vue` -- `views/products/` — grid + tabbed edit modal (General / Tags & API / Versions) -- `views/admin/` — GeneralSettings (master key), Users, AccessLog, GeoIP; `views/AnalyticsView.vue` +- `layouts/AppLayout.vue` — one shared shell for landing + admin (header always; sidebar for + logged-in admins everywhere); `views/LandingView.vue` renders inside it +- `views/products/` — grid + tabbed edit modal (General / **Access** / Tags & API / Versions); + Access tab = Restricted toggle + principal picker (groups + users) +- `views/groups/` — Groups grid + tabbed modal (General / Members / Auto-membership with + `CoarScriptEditor` from `@cocoar/vue-script-editor` + dry-run) +- `views/admin/` — GeneralSettings (master key), Users, Groups, AccessLog, GeoIP; + `views/AnalyticsView.vue` — dashboard with world map (`@cocoar/vue-map`), flags (`flag-icons`) +- `core/geo.ts` — country code → name (`Intl.DisplayNames`) + SVG flag class (flag-icons; + emoji flags don't render on Windows) - Conventions: labels via `CoarFormField` (inputs have NO label prop — also true in vue-ui 2.x), card titles via `CoarCard` `#header` slot, destructive actions via `useDialog().confirm`, grids bind `rowDataRef(computed(() => store.items))` to Pinia stores; bump @@ -109,3 +145,8 @@ Frontend (`src/Cocoar.Shelf.Client/src/`): - **Public read endpoints stay unauthenticated**; keys/admin data are admin-gated - **Access log counts page views** (HTML GETs), not assets or HEAD requests - **Admin UI is desktop-only** — no mobile optimization +- **Access control is opt-in** — `Restricted` defaults false; unrestricted products behave exactly as before +- **Grants live on the product** (`ReadPrincipals`), not the group — you assign who may read where you toggle Restricted; groups/users are both `IPrincipal` +- **Restricted → 404, not 403** for authed-no-grant (no existence leak); restricted products are fully invisible (no teaser) +- **Analytics aggregation is server-side** — the browser only renders; country flags/names are derived client-side from the ISO code (display only) +- **modgud owns identity, Shelf owns authorization** — groups/grants reference products, which only exist in Shelf, so revocation is immediate (per request, not next login) diff --git a/src/Cocoar.Shelf.Client/package.json b/src/Cocoar.Shelf.Client/package.json index 95f1b5d..7544539 100644 --- a/src/Cocoar.Shelf.Client/package.json +++ b/src/Cocoar.Shelf.Client/package.json @@ -11,7 +11,10 @@ "dependencies": { "@cocoar/vue-data-grid": "2.17.1", "@cocoar/vue-fragment-parser": "2.17.1", + "@cocoar/vue-map": "2.17.1", + "@cocoar/vue-script-editor": "2.17.1", "@cocoar/vue-ui": "2.17.1", + "flag-icons": "^7.5.0", "overlayscrollbars": "^2.16.0", "pinia": "^4.0.2", "vue": "^3.5.40", diff --git a/src/Cocoar.Shelf.Client/pnpm-lock.yaml b/src/Cocoar.Shelf.Client/pnpm-lock.yaml index 03e5eae..2952b3a 100644 --- a/src/Cocoar.Shelf.Client/pnpm-lock.yaml +++ b/src/Cocoar.Shelf.Client/pnpm-lock.yaml @@ -14,9 +14,18 @@ importers: '@cocoar/vue-fragment-parser': specifier: 2.17.1 version: 2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)))(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) + '@cocoar/vue-map': + specifier: 2.17.1 + version: 2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) + '@cocoar/vue-script-editor': + specifier: 2.17.1 + version: 2.17.1(monaco-editor@0.55.1)(vue@3.5.40(typescript@6.0.3)) '@cocoar/vue-ui': specifier: 2.17.1 version: 2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) + flag-icons: + specifier: ^7.5.0 + version: 7.5.0 overlayscrollbars: specifier: ^2.16.0 version: 2.16.0 @@ -118,6 +127,21 @@ packages: peerDependencies: vue: ^3.5.0 + '@cocoar/vue-map@2.17.1': + resolution: {integrity: sha512-kTuEg3/lciuhf0lgWLczPuBEjpdS6/anXalfg4XKUsKE6PDFTEQeazX1wo3iYaP2nGHoUkrlRS0qrfrnkbF+FQ==} + peerDependencies: + '@cocoar/vue-ui': 2.17.1 + vue: ^3.5.0 + peerDependenciesMeta: + '@cocoar/vue-ui': + optional: true + + '@cocoar/vue-script-editor@2.17.1': + resolution: {integrity: sha512-untRHzKtOnJpD57IObaE0NOk5cGtvXtjnyxKPflPS9dnfhpaVg2d1UEkj5TLbwaZgFh/Fzb/AkW1po8xIQHaPA==} + peerDependencies: + monaco-editor: ^0.55.1 + vue: ^3.5.0 + '@cocoar/vue-ui@2.17.1': resolution: {integrity: sha512-VYOoYWTKjDfvU0cS7GtvYbXXFSutFAZLswQq4SBIwsqwytKk7iTtCgCT5+XBbnmSIpxoiPTO7nMxpJuouMppMQ==} peerDependencies: @@ -374,6 +398,9 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitejs/plugin-vue@6.0.8': resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -485,6 +512,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + enhanced-resolve@5.24.2: resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} engines: {node: '>=10.13.0'} @@ -508,6 +538,9 @@ packages: picomatch: optional: true + flag-icons@7.5.0: + resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -536,6 +569,9 @@ packages: engines: {node: '>=6'} hasBin: true + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -617,9 +653,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -901,6 +945,18 @@ snapshots: dependencies: vue: 3.5.40(typescript@6.0.3) + '@cocoar/vue-map@2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))': + dependencies: + leaflet: 1.9.4 + vue: 3.5.40(typescript@6.0.3) + optionalDependencies: + '@cocoar/vue-ui': 2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) + + '@cocoar/vue-script-editor@2.17.1(monaco-editor@0.55.1)(vue@3.5.40(typescript@6.0.3))': + dependencies: + monaco-editor: 0.55.1 + vue: 3.5.40(typescript@6.0.3) + '@cocoar/vue-ui@2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))': dependencies: '@cocoar/vue-localization': 2.17.1(vue@3.5.40(typescript@6.0.3)) @@ -1108,6 +1164,9 @@ snapshots: '@types/jsesc@2.5.1': {} + '@types/trusted-types@2.0.7': + optional: true + '@vitejs/plugin-vue@6.0.8(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1253,6 +1312,10 @@ snapshots: detect-libc@2.1.2: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + enhanced-resolve@5.24.2: dependencies: graceful-fs: 4.2.11 @@ -1268,6 +1331,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + flag-icons@7.5.0: {} + fsevents@2.3.3: optional: true @@ -1283,6 +1348,8 @@ snapshots: json5@2.2.3: {} + leaflet@1.9.4: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1346,6 +1413,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@14.0.0: {} + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -1353,6 +1422,11 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + muggle-string@0.4.1: {} nanoid@3.3.16: {} diff --git a/src/Cocoar.Shelf.Client/src/components/TopList.vue b/src/Cocoar.Shelf.Client/src/components/TopList.vue index 4b4acb2..4a7fc6c 100644 --- a/src/Cocoar.Shelf.Client/src/components/TopList.vue +++ b/src/Cocoar.Shelf.Client/src/components/TopList.vue @@ -2,7 +2,10 @@
- {{ item.label }} + + + {{ item.label }} + {{ item.count }}
@@ -14,9 +17,11 @@ diff --git a/src/Cocoar.Shelf.Client/src/views/DashboardView.vue b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue index 4d49ba0..cc7475d 100644 --- a/src/Cocoar.Shelf.Client/src/views/DashboardView.vue +++ b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue @@ -1,7 +1,11 @@ + + Access + + + Tags & API