- C# 38%
- TypeScript 32.7%
- HTML 14.6%
- CSS 8.2%
- JavaScript 6.5%
| .claude | ||
| .forgejo/workflows | ||
| .run | ||
| docker | ||
| docs | ||
| src | ||
| tests | ||
| ui/alexandria-ui | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| Alexandria.slnx | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| README.md | ||
| TODO.md | ||
Alexandria
A personal platform of tools behind one shared UI. The first service is Nova.Luna — a
personal knowledge vault: markdown notes with folders, revision history, soft delete, and search.
A second, platform-wide service, Alexandria.Iam, provides identity or any current/future
Alexandria service to validate against. Solution file: Alexandria.slnx.
Layout
Alexandria/
├── Alexandria.slnx
├── src/
│ ├── Alexandria.Iam.Common/ # shared DTOs (RegisterRequest, TokenResponse, UserDto, ...)
│ ├── Alexandria.Iam.Data/ # EF Core: IamDbContext, entities, repositories, migrations
│ ├── Alexandria.Iam.Logic/ # AuthService, TokenService (JWT issuance), password hashing
│ ├── Alexandria.Iam.Api/ # ASP.NET Core Web API — issues IAM's JWTs
│ ├── Nova.Luna.Common/ # shared DTOs, the PatchValue<T> 3-state PATCH mechanism, FolderPaths
│ ├── Nova.Luna.Data/ # EF Core: AppDbContext, entities, repositories, migrations
│ ├── Nova.Luna.Logic/ # business/service layer, exceptions, password hashing
│ └── Nova.Luna.Api/ # ASP.NET Core Web API (MVC controllers), validates Alexandria.Iam JWTs, middleware
├── tests/
│ ├── Alexandria.Iam.Logic.Tests/ # xUnit, EF Core InMemory
│ └── Nova.Luna.Logic.Tests/ # xUnit, EF Core InMemory — covers the Logic layer end-to-end
└── ui/
└── alexandria-ui/ # React + TypeScript frontend (Vite)
Project references: Api → Logic, Common · Logic → Data, Common · Data → Common · Common → (none),
independently for each of Nova.Luna.* and Alexandria.Iam.* — the two service families don't
reference each other at all (see Alexandria.Iam below for
why, even though Nova.Luna validates IAM's tokens at runtime).
What's real
- Auth — entirely Alexandria.Iam's: self-service registration, JWT access + rotated refresh
tokens, change password. Nova.Luna has no user store or session mechanism of its own; every
endpoint validates a signed Alexandria.Iam access token (
OwnerIdon vault rows is a bare claim from that token, not a local FK). See Alexandria.Iam below. - Vault item CRUD — create/read/update (3-state PATCH — see
Nova.Luna.Common/Patching/PatchValue.cs)/delete, all scoped per-user. - Folders — a folder is a plain path string on an item (e.g.
"Work/Projects"), and a persistedFolderrow (so a folder survives even with zero items in it). SeeNova.Luna.Common/FolderPaths.csandFolderRepository.EnsureHierarchyExistsAsync. - Revision history — every save that changes an item's title/content appends an immutable
snapshot. See
VaultItemRevision/VaultItemService's revision-bumping logic. - Soft delete + undo + purge — deleting sets
DeletedAtrather than removing the row; an 8-second undo toast in the UI callsrestore; a background job (PurgeHostedService/PurgeService) hard-deletes anything past the 30-day retention window. - Search — real substring match against title/content, with a genuinely measured hit
count/elapsed-ms (not placeholders). See
VaultItemService.SearchAsync. - Editor — a formatting toolbar (bold/italic/headings/lists/quote/link/code) and a live
side-by-side raw/rendered markdown preview. See
VaultItemPage.tsx. - Dark mode / custom themes — a light theme (default), a dark theme, and user-defined custom
theme sets, switchable from Settings. See
ui/alexandria-ui/src/context/ThemeContext.tsx. Auto switch by time of day and a manual schedule override are still planned — see TODO.md. - Todos — quick-add todo items with a due date (
Today/Tomorrowlanes), complete/uncomplete (with an undo path), and a separate archive view. Hard-deleted, no revision history — unlike a vault item there's no editing-history value in a todo. SeeTodoItemsController,TodoService,ui/alexandria-ui/src/pages/TodoPage.tsx/TodoArchivePage.tsx. - Chronicles — structured, evolving-facts notes (e.g. "1:1s with Dana"), distinct from a
freeform vault item: a Chronicle holds named Facts, each with a full history of dated/sourced
entries (
UpdateorCorrection) — the fact's "current" value is always its most-recently-added entry, not the one with the latest as-of date. Soft-deleted with a required deletion reason as a lightweight audit trail (no restore path). SeeChronicleController,ChronicleService,ui/alexandria-ui/src/pages/ChroniclePage.tsx. - Tags and internal linking/backlinking — a shared
Tagpool usable by both vault items and Chronicles (type-to-suggest/Enter-to-createTagInput), and[[wiki-link]]syntax in a note's markdown resolved to a target vault item or Chronicle at save time and stored by id (so a rename doesn't break the link) — seeNoteLink,LinkService,WikiLinkParser. Only notes author links; a Chronicle can be a link target and shows real incoming backlinks. A title with no match at save time just stays as plain unlinked text. - Attachments — files attached to a
VaultItem, uploaded/downloaded directly between the browser and object storage rather than through Nova.Luna.Api: the API only issues short-lived presigned S3 URLs and verifies the upload afterward. See Attachment storage (SeaweedFS) below. - Focus timer — a frontend-only pomodoro timer (25 min session / 5 min break by default, both
user-adjustable, dot-tracked cycles), timestamp-based so it survives tab throttling, persisted to
localStorage. Shares a single shell-level 380px lane with the Listening rail below (see next bullet) — whichever one owns the lane demotes to a dashed pointer row in the leftSectionRaildock, the other shows its full card./todo/archiveis the one route outsideAppShelland keeps the older standalone popover on its own globalHeader. Session/break minutes and named presets (multiple saved pairs, applied from/settings) are also client-side/localStorage-only — no backend entity or "session" concept exists for the timer itself yet. Seeui/alexandria-ui/src/context/FocusTimerContext.tsx,RailContext.tsx,ui/alexandria-ui/src/pages/SettingsPage.tsx. - Listening rail — a Spotify-flavored rail sharing the same shell lane as the Focus timer (see
above): now-playing block, Playlists/Queue/Recent tabs, and a custom shuffle-engine footer, plus a
merged ⌘K command sheet (vault search + track search + actions,
⌘⇧S/⌘⇧P/⌘⇧Fglobal shortcuts). A dev-onlylocalStoragefixture (alexandria-music-fixture) populates it without a real Spotify connection. Seeui/alexandria-ui/src/components/ListeningRail.tsx,LaneSlot.tsx,context/MusicContext.tsx. - User preferences (rail/card/lane layout) — the left-hand
SectionRail's collapse state, the vault meter card's independent minimize state, and the Focus/Listening lane's open/content state persist server-side per-owner via a smallUserPreferencesentity (GET/PUT /api/preferences), notlocalStorage— the first backend-persisted "setting" in Nova.Luna. SeePreferencesController,UserPreferencesService,ui/alexandria-ui/src/api/preferencesClient.ts.spotifyConnectedon that same entity is read-only from the preferences PUT (only a future OAuth callback will set it) and now drives real logic:GET /api/spotify/statereportsconnection: "ready"when it'strueand"disconnected"otherwise. Every other Spotify action —POST /api/spotify/{connect,play,pause,next,previous,queue}andGET /api/spotify/search— is an intentional501stub pending real Spotify OAuth and the Web Playback SDK. SeeSpotifyController,ISpotifyService. That controller'ssettingspair (GET/PUT /api/spotify/settings,PUTisAdmin-only) holds the deployment-wide Spotify Client ID an admin can set from/adminahead of that OAuth flow existing — a Client ID isn't a secret (PKCE needs no client secret), soGETis open to any authenticated user. SeeSpotifyAppSettingsService. - Time-of-day and weather ambiance — a location (US zip code, geocoded client-side and cached),
time-of-day period, and live weather condition/hazard alerts (Open-Meteo pinned to NOAA's HRRR
model, api.weather.gov for active NWS alerts — both free/keyless) drive a header banner and
optional full-screen canvas effects (rain/snow/fog/thunderstorm/wind/heat/cold/fire/tornado/etc.)
that a theme can opt into. Currently shown only on pages that use the old global header
(
/vault/new,/vault/:id,/todo/archive,/settings) — the three-column app shell's own header (/,/todo,/chronicle*) dropped it for now, tracked as a follow-up inTODO.md./admin's Preview panel (admin-only) can override period/weather/hazard to test a theme's reaction without waiting for the real conditions. Seeui/alexandria-ui/src/context/AmbianceContext.tsxandcomponents/weather-effects/. - Local-model connection (admin-only) —
/admin's "Local model" panel lets an admin point the app at an LMStudio server (test-connect + model list), with a header pill reflecting live connection status. Wiring only — nothing calls the model yet; see Roadmap below. - Admin console —
/admin(Admin-role-gated; linked fromSectionRail's profile popover) holds the deployment-wide admin tools above (Preview, Local model, Spotify Client ID) plus a "Platform" panel that callsGET /api/admin/whoamito prove the role claim is server-enforced. Its "Users & Groups" panel is a deliberate placeholder, not a stub UI — real user/role/group management is blocked on Groups existing; see Roadmap below.
Every backend piece above has both an XML-doc'd interface and a small but real xUnit suite under
tests/Nova.Luna.Logic.Tests (and tests/Alexandria.Iam.Logic.Tests for the IAM service below) —
run either with dotnet test.
Alexandria.Iam (basic identity service)
A separate, platform-wide identity service — its own project family, its own database (on the
same SQL Server instance as Nova.Luna's, but a different DB), meant to eventually be the thing
every Alexandria service validates identity against instead of each owning its own Users table.
What's real today:
- Register / login / refresh / logout / change password, all token-based (
AuthControllerinAlexandria.Iam.Api) — no cookies. Access tokens are short-lived signed JWTs; refresh tokens are long-lived opaque strings, rotated on every use (a used-and-replayed refresh token fails). - The very first account ever registered becomes Admin automatically — no separate bootstrap
endpoint or shared secret. See
AuthService.RegisterAsync. Every account after that starts with no roles. - Users + Roles + a UserRole join table. Just one seeded role today (
Admin) — Groups (bundles of roles assignable to many users at once) are intentionally not built yet; see TODO.md. - Nova.Luna validates Alexandria.Iam's tokens on every endpoint — the whole vault API
(
VaultItemsController,FoldersController,VaultController) is[Authorize]'d against the JWT Bearer scheme, plusGET /api/admin/whoami(AdminController) additionally requires theAdminrole. There's no localUserstable on Nova.Luna's side at all — the IAM migration (previously tracked as a roadmap item) is done. - The frontend talks to Iam directly, proxied under
/iam-apiby Vite in dev (seevite.config.ts) so the browser still only ever hits one origin. The access token lives in-memory (ui/alexandria-ui/src/api/tokenStore.ts); the refresh token persists tolocalStorageand is what session restore on page load redeems. Seeui/alexandria-ui/src/context/AuthContext.tsx. - Two unused, nullable columns on
User(ExternalProvider,ExternalSubjectId) exist so that federating an account with an external identity provider (e.g. Authentik via OIDC) won't need a breaking migration later — the federation flow itself (discovery, redirect/callback, account linking) isn't built. Tracked in TODO.md.
Deliberately not shared with Nova.Luna: Alexandria.Iam.Logic's PasswordHasher is a
duplicate of Nova.Luna.Logic's (same ~40 lines), and Nova.Luna.Api's JWT validation setup has no
project reference to any Alexandria.Iam.* project — it only needs to agree with IAM on the
Iam:JwtSigningKey config value and the issuer/audience strings. The two service families are
meant to be independently deployable; a shared library between them would undercut that.
Attachment storage (SeaweedFS)
Attachment binaries never touch Nova.Luna's relational database or pass through Nova.Luna.Api itself — they live in SeaweedFS's S3-compatible gateway, and the browser talks to it directly using short-lived presigned URLs the API generates. The flow:
- React sends file metadata (name, content type, size) to
POST /api/vaultitems/{id}/attachments. - Nova.Luna.Api authenticates the caller, authorizes them against the parent
VaultItem, validates the metadata (Nova.Luna.Common.AttachmentValidation), creates aPendingrow under a server-generated id, and returns a presigned PUT URL — never storage credentials. - React
PUTs the file directly to that URL. - React calls
POST /api/attachments/{id}/complete; the API checks the object actually exists in storage (and that its size matches what was declared) before marking the rowUploaded. - Download/delete follow the same shape:
GET /api/attachments/{id}/downloadreturns a presigned GET URL after authorizing;DELETE /api/attachments/{id}removes the object from storage and soft-deletes the row.
Everything storage-specific lives behind Nova.Luna.Logic.Storage.IObjectStorage — the concrete
S3ObjectStorage implementation uses the plain AWS SDK for .NET pointed at a custom endpoint, so
swapping SeaweedFS for real S3 (or another S3-compatible provider) later is a configuration change,
not a code change.
Configuration — a Storage section, same shape in appsettings.json (dev) or the
Storage__* environment variables (Docker, see .env.example):
| Key | Meaning |
|---|---|
Storage:Endpoint |
SeaweedFS's S3 gateway URL, e.g. http://seaweedfs.internal:8333. Private/LAN-only — never sent to the browser. Plain http:// is fine for a LAN-only deployment; there's no requirement to put TLS in front of it. |
Storage:Bucket |
Created automatically on startup if it doesn't already exist (see ServiceCollectionExtensions.EnsureBucketExistsAsync, called from Program.cs next to the EF Core migration step). |
Storage:Region |
Ignored by SeaweedFS, but the AWS SDK's request signer requires some value — defaults to us-east-1. |
Storage:ForcePathStyle |
Must stay true for SeaweedFS (host/bucket/key addressing, not bucket.host/key). |
Storage:AllowedOrigin |
The frontend's real origin (e.g. http://localhost:5173 in dev). Applied as the bucket's CORS policy on every startup — the browser's direct PUT/GET to SeaweedFS needs this, since that traffic never goes through Nova.Luna.Api's own CORS policy. Deliberately never a wildcard *. |
Storage:UploadUrlExpirationMinutes / Storage:DownloadUrlExpirationMinutes |
Presigned URL lifetimes — default 10/15. |
Storage:MaxFileSizeBytes |
Server-side upload size limit, enforced independently of anything the browser checks — default 104857600 (100 MB). |
Endpoint, Bucket, and AllowedOrigin are validated eagerly at startup (StorageOptions +
ValidateOnStart) — a missing value fails the app at boot with a clear message rather than on the
first upload request.
No access key/secret, for now. SeaweedFS is run with no identity config, so its S3 gateway
accepts every request regardless of what's (or isn't) in the Authorization header — anyone who
can reach Storage:Endpoint can read/write/delete any object in the bucket. S3ObjectStorage
still signs requests with the AWS SDK (a fixed placeholder credential — see
ServiceCollectionExtensions.AddNovaLunaObjectStorage) because the SDK requires some non-empty
credential to compute a signature, but that signature is never checked server-side. This is
acceptable only because Storage:Endpoint is private/LAN-only, not internet-reachable. Revisit
before deploying anywhere the SeaweedFS endpoint itself might be exposed.
Roadmap / not yet implemented
These are scaffolded (real routes/entities/interfaces exist) but intentionally not built —
calling them returns a clear 501 Not Implemented rather than silently doing nothing. Each has an
implementation-plan comment block at its stub site.
| Feature | Where the stub lives | Notes |
|---|---|---|
| Local-model AI assist (tag/link suggestions) | VaultItemPage.tsx's "Assist" rail |
The rail is still purely decorative — no inference happens anywhere. /admin's "Local model" panel (admin-only) now lets an admin connect to an LMStudio server (test-connect + model list, header pill wired to real status — see What's real above), but nothing calls the model yet; needs the actual inference trigger and a real subsystem behind it. |
| Admin console: Users/Groups management | AdminPage.tsx's "Users & Groups" panel |
/admin itself is real (see What's real above) — this row is specifically about managing users/roles/groups, which needs Groups to exist first. See Alexandria.Iam above. |
| OIDC federation with Authentik | User.ExternalProvider/ExternalSubjectId (unused nullable columns) |
"Bonus points" ask, deliberately deferred — real scope on its own (discovery, redirect/callback, account linking), not part of "basic IAM". |
Planned: dark mode auto-switching
The light/dark/custom-theme system itself is real — see What's real above and
ui/alexandria-ui/src/context/ThemeContext.tsx. Still planned, not built:
- Auto switch by time of day — computed from the user's rough dawn/dusk times, derived from timezone plus a location signal (zip code or similar), so the theme can flip on its own around sunrise/sunset.
- Manual time window override — a user can bypass the computed dawn/dusk times and set an explicit light/dark schedule instead.
- Animated transition — the original vision was a slow cream-to-night crossfade with stars fading in, rather than the instant CSS-variable swap that shipped (chosen for simplicity and a flicker-free reload guarantee).
The two switching items need a preferences surface (timezone, location signal, auto vs. manual mode, override window) that doesn't exist yet — likely a per-user settings entity/endpoint.
First-time setup
-
Connection string. Set your real SQL Server connection string via .NET User Secrets — it's never stored in
appsettings.jsonor committed. Passwords containing!will trigger bash history expansion if typed directly — useread -sto avoid that:read -s -p "SQL password: " SQL_PW; echo dotnet user-secrets set "ConnectionStrings:Default" \ "Server=<SERVER>,<PORT>;Database=<DB>;User Id=<USER>;Password=$SQL_PW;TrustServerCertificate=True" \ --project src/Nova.Luna.Api unset SQL_PW(Note the comma between server and port, not a colon —
Server=host:portgets parsed as a single literal hostname and fails DNS resolution.) -
Create the database schema. The Data project holds the migrations; the Api project holds the startup config (including the connection string above), so both flags are needed:
dotnet ef database update --project src/Nova.Luna.Data --startup-project src/Nova.Luna.Api(Migrations are already checked in — you only need
database update, notmigrations add, unless you're changing the entities yourself.) -
Alexandria.Iam's connection string and shared signing key. Same SQL Server instance, a different database (
AlexandriaIambelow, or whatever you name it) — and a signing key that must be set to the same value on bothAlexandria.Iam.ApiandNova.Luna.Api, since Nova.Luna validates tokens IAM signs without ever calling back into it:read -s -p "SQL password: " SQL_PW; echo dotnet user-secrets set "ConnectionStrings:Default" \ "Server=<SERVER>,<PORT>;Database=AlexandriaIam;User Id=<USER>;Password=$SQL_PW;TrustServerCertificate=True" \ --project src/Alexandria.Iam.Api unset SQL_PW JWT_KEY=$(openssl rand -base64 32) dotnet user-secrets set "Iam:JwtSigningKey" "$JWT_KEY" --project src/Alexandria.Iam.Api dotnet user-secrets set "Iam:JwtSigningKey" "$JWT_KEY" --project src/Nova.Luna.Api unset JWT_KEY dotnet ef database update --project src/Alexandria.Iam.Data --startup-project src/Alexandria.Iam.Api -
Node.js. The frontend needs Node/npm. If you don't have it, install via nvm.
Running
Three processes, in separate terminals, from the repo root:
dotnet run --project src/Nova.Luna.Api
dotnet run --project src/Alexandria.Iam.Api
cd ui/alexandria-ui
npm install # first time only; after that, prefer `npm ci` (see CLAUDE.md's Commands section)
npm run dev
The Vite dev server proxies /api/* to Nova.Luna at http://localhost:5247 and /iam-api/* to
Alexandria.Iam at http://localhost:5248 (rewritten back to /api/* on the way through — see
vite.config.ts), so the browser only ever talks to http://localhost:5173 regardless of which
backend a request actually hits. If you run either API with --launch-profile https instead of
the default, update the matching proxy target.
Registering through the UI's Register page hits Alexandria.Iam — the very first account on a
fresh AlexandriaIam DB becomes Admin automatically. To poke at the APIs directly instead: POST http://localhost:5248/api/auth/register with a JSON body {"username": "...", "password": "..."},
then use the returned accessToken as a Bearer token against
GET http://localhost:5247/api/admin/whoami.
Local test account. For local development/visual review, the go-to seeded account is
tester / testtest (password minimum is 8 characters, so the shorter test is rejected). It
holds sample vault items, todos, and chronicles. It is a non-admin account — it was not the first
account registered, so admin-only surfaces (the Admin page, Settings → Preview) require a separate
account that was first on the DB. These are throwaway local credentials only; never reuse them
anywhere real.
Deployment (Docker)
docker-compose.yml builds and runs all three pieces — nova-luna, iam, and ui (nginx,
serving the built frontend and reverse-proxying /api and /iam-api to the two backend
containers, mirroring vite.config.ts's dev proxy). See docker/Dockerfile.nova-luna,
docker/Dockerfile.iam, docker/Dockerfile.ui, and docker/nginx.conf.
cp .env.example .env # fill in real connection strings and a real Iam:JwtSigningKey value
docker compose up -d --build
.env is gitignored; .env.example holds placeholder values only — never commit real
connection strings or the signing key.
Keep the Docker files in sync with the code. They're a second description of how to run this
app (project paths, config keys, routes) that nothing enforces automatically. If you add/rename a
config key, project reference, controller route, or bump TargetFramework, update
docker-compose.yml, the relevant docker/Dockerfile.*, docker/nginx.conf, and
.env.example in the same change — see CLAUDE.md's "Docker deployment" section for the specific
mapping. After editing, docker compose config (against a .env copied from .env.example)
will catch a missing or renamed required variable before it becomes a runtime failure.
Testing
dotnet test tests/Nova.Luna.Logic.Tests/Nova.Luna.Logic.Tests.csproj
dotnet test tests/Alexandria.Iam.Logic.Tests/Alexandria.Iam.Logic.Tests.csproj
Uses EF Core's InMemory provider (not SQLite — see InMemoryDbTestBase's doc comment for why) so
tests run without a real database. No frontend test suite exists yet — this was deferred rather
than writing tests against UI that was still being actively rewritten; add one (Vitest + React
Testing Library is the natural fit for this Vite + React setup) once the UI is stable.