A place of beauty to house a world of knowledge
  • C# 38%
  • TypeScript 32.7%
  • HTML 14.6%
  • CSS 8.2%
  • JavaScript 6.5%
Find a file
Korbinian Asbjorn 3d0e286fa4
Some checks failed
Build, test, and deploy / build-and-test (push) Has been cancelled
Build, test, and deploy / build-and-push (push) Has been cancelled
Build, test, and deploy / deploy (push) Has been cancelled
Workflow QoL
2026-08-25 17:23:25 -04:00
.claude added unversioned plans 2026-08-22 10:24:07 -04:00
.forgejo/workflows added dockerfile changes 2026-08-18 17:53:23 -04:00
.run update folder adding 2026-08-18 00:37:54 -04:00
docker added dockerfile changes 2026-08-18 17:53:23 -04:00
docs Workflow QoL 2026-08-25 17:23:25 -04:00
src Fixed bugs in the new sound sidebar/rail 2026-08-22 11:34:55 -04:00
tests Fixed bugs in the new sound sidebar/rail 2026-08-22 11:34:55 -04:00
ui/alexandria-ui Workflow QoL 2026-08-25 17:23:25 -04:00
.dockerignore fixed docker ignore issue 2026-08-21 18:49:14 -04:00
.env.example added attachment storage logic 2026-08-21 18:43:23 -04:00
.gitattributes Updated documentation 2026-08-24 11:55:09 -04:00
.gitignore Updated documentation 2026-08-24 11:55:09 -04:00
Alexandria.slnx added iam 2026-08-17 18:31:33 -04:00
CLAUDE.md Updated documentation 2026-08-24 11:55:09 -04:00
docker-compose.yml added attachment storage logic 2026-08-21 18:43:23 -04:00
README.md Workbench frame: Home hub, collapsible Pomodoro, placeholder parts 2026-08-25 14:20:58 -04:00
TODO.md Workflow QoL 2026-08-25 17:23:25 -04:00

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 (OwnerId on 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 persisted Folder row (so a folder survives even with zero items in it). See Nova.Luna.Common/FolderPaths.cs and FolderRepository.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 DeletedAt rather than removing the row; an 8-second undo toast in the UI calls restore; 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/Tomorrow lanes), 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. See TodoItemsController, 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 (Update or Correction) — 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). See ChronicleController, ChronicleService, ui/alexandria-ui/src/pages/ChroniclePage.tsx.
  • Tags and internal linking/backlinking — a shared Tag pool usable by both vault items and Chronicles (type-to-suggest/Enter-to-create TagInput), 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) — see NoteLink, 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 left SectionRail dock, the other shows its full card. /todo/archive is the one route outside AppShell and keeps the older standalone popover on its own global Header. 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. See ui/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/⌘⇧F global shortcuts). A dev-only localStorage fixture (alexandria-music-fixture) populates it without a real Spotify connection. See ui/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 small UserPreferences entity (GET/PUT /api/preferences), not localStorage — the first backend-persisted "setting" in Nova.Luna. See PreferencesController, UserPreferencesService, ui/alexandria-ui/src/api/preferencesClient.ts. spotifyConnected on 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/state reports connection: "ready" when it's true and "disconnected" otherwise. Every other Spotify action — POST /api/spotify/{connect,play,pause,next,previous,queue} and GET /api/spotify/search — is an intentional 501 stub pending real Spotify OAuth and the Web Playback SDK. See SpotifyController, ISpotifyService. That controller's settings pair (GET/PUT /api/spotify/settings, PUT is Admin-only) holds the deployment-wide Spotify Client ID an admin can set from /admin ahead of that OAuth flow existing — a Client ID isn't a secret (PKCE needs no client secret), so GET is open to any authenticated user. See SpotifyAppSettingsService.
  • 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 in TODO.md. /admin's Preview panel (admin-only) can override period/weather/hazard to test a theme's reaction without waiting for the real conditions. See ui/alexandria-ui/src/context/AmbianceContext.tsx and components/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 from SectionRail's profile popover) holds the deployment-wide admin tools above (Preview, Local model, Spotify Client ID) plus a "Platform" panel that calls GET /api/admin/whoami to 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 (AuthController in Alexandria.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, plus GET /api/admin/whoami (AdminController) additionally requires the Admin role. There's no local Users table 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-api by Vite in dev (see vite.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 to localStorage and is what session restore on page load redeems. See ui/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:

  1. React sends file metadata (name, content type, size) to POST /api/vaultitems/{id}/attachments.
  2. Nova.Luna.Api authenticates the caller, authorizes them against the parent VaultItem, validates the metadata (Nova.Luna.Common.AttachmentValidation), creates a Pending row under a server-generated id, and returns a presigned PUT URL — never storage credentials.
  3. React PUTs the file directly to that URL.
  4. 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 row Uploaded.
  5. Download/delete follow the same shape: GET /api/attachments/{id}/download returns 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

  1. Connection string. Set your real SQL Server connection string via .NET User Secrets — it's never stored in appsettings.json or committed. Passwords containing ! will trigger bash history expansion if typed directly — use read -s to 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:port gets parsed as a single literal hostname and fails DNS resolution.)

  2. 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, not migrations add, unless you're changing the entities yourself.)

  3. Alexandria.Iam's connection string and shared signing key. Same SQL Server instance, a different database (AlexandriaIam below, or whatever you name it) — and a signing key that must be set to the same value on both Alexandria.Iam.Api and Nova.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
    
  4. 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.