Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c1ef845
feat(auth): restrict product writes to admins (Access Control v2, Blo…
windischb Jul 18, 2026
53b0ab1
feat(auth): access-control-v2 backend foundation — groups + JsEval au…
windischb Jul 18, 2026
6304265
feat(auth): enforce restricted-product access + groups API (Block 2c)
windischb Jul 18, 2026
9cdfa86
feat(ui): access-control-v2 admin UI — groups page, product restricte…
windischb Jul 18, 2026
988f6db
feat(ui): use CoarScriptEditor (Monaco) for the membership script
windischb Jul 18, 2026
7bd3274
refactor(auth): product-side principal grants (groups + users as IPri…
windischb Jul 19, 2026
b9c9799
feat(ui): product access picker (groups + users) + Groups under Admin…
windischb Jul 19, 2026
5d1a81f
feat(ui): move restricted access into its own Access tab in the produ…
windischb Jul 19, 2026
f2169cb
feat(ui): unify landing + admin into one shell (shared header, dark m…
windischb Jul 19, 2026
490b013
feat(ui): show the sidebar for logged-in admins everywhere (incl. lan…
windischb Jul 19, 2026
b34261b
feat(analytics): richer server-side aggregation (cities, geo points, …
windischb Jul 19, 2026
b74f7d8
feat(ui): detailed analytics dashboard with world map, flags, breakdowns
windischb Jul 19, 2026
3d1f3a7
fix(ui): render real SVG country flags (emoji flags don't show on Win…
windischb Jul 19, 2026
82c7bf8
fix(ui): give Groups a rendering icon (users-round isn't in the icon …
windischb Jul 19, 2026
3c21094
docs: 2.1.0 changelog + access-control & analytics docs (fix stale OT…
windischb Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 51 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` / `#<id>`; 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
Expand All @@ -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)
3 changes: 3 additions & 0 deletions src/Cocoar.Shelf.Client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions src/Cocoar.Shelf.Client/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions src/Cocoar.Shelf.Client/src/components/TopList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
<div class="top-list">
<div v-for="item in items" :key="item.label" class="top-item">
<div class="top-line">
<span class="top-label" :title="item.label">{{ item.label }}</span>
<span class="top-label" :title="item.label">
<span v-if="item.flag" :class="flagClass(item.flag)" class="top-flag" />
{{ item.label }}
</span>
<span class="top-count">{{ item.count }}</span>
</div>
<div class="top-bar-track">
Expand All @@ -14,9 +17,11 @@

<script setup lang="ts">
import { computed } from 'vue';
import { flagClass } from '@/core/geo';

const props = defineProps<{
items: { label: string; count: number }[];
/** Each item may carry an optional `flag` = ISO country code, rendered as an SVG flag prefix. */
items: { label: string; count: number; flag?: string }[];
}>();

const max = computed(() => Math.max(1, ...props.items.map(i => i.count)));
Expand Down Expand Up @@ -48,6 +53,12 @@ function barWidth(count: number): string {
min-width: 0;
}

.top-flag {
margin-right: 6px;
border-radius: 2px;
vertical-align: -1px;
}

.top-count {
color: var(--coar-text-neutral-secondary);
font-variant-numeric: tabular-nums;
Expand Down
Loading
Loading