diff --git a/site/src/components/case-study/FeatureList.astro b/site/src/components/case-study/FeatureList.astro index a36e2e9..3d8b195 100644 --- a/site/src/components/case-study/FeatureList.astro +++ b/site/src/components/case-study/FeatureList.astro @@ -21,7 +21,7 @@ const { features } = Astro.props; {f.title}
{f.value}
-{f.technical}
+{f.technical}
)) } diff --git a/site/src/data/projects/inboxintel.ts b/site/src/data/projects/inboxintel.ts index 69b107c..52575ad 100644 --- a/site/src/data/projects/inboxintel.ts +++ b/site/src/data/projects/inboxintel.ts @@ -155,7 +155,7 @@ export const inboxintel: Project = { blocks: [ { type: 'p', - text: 'A busy inbox is hard to search and dangerous to clean up — keyword search misses what you meant, and one wrong bulk filter deletes things you can\'t get back. I built InboxIntel to make an inbox actually searchable (by meaning, not just words), understandable at a glance, and safe to tidy — self-hosted, with a clean, testable backend built to production discipline rather than side-project shortcuts.', + text: "A busy inbox is hard to search and dangerous to clean up — keyword search misses what you meant, and one wrong bulk filter deletes things you can't get back. I built InboxIntel to make an inbox actually searchable (by meaning, not just words), understandable at a glance, and safe to tidy — self-hosted, with a clean, testable backend built to production discipline rather than side-project shortcuts.", }, ], }, @@ -223,8 +223,8 @@ export const inboxintel: Project = { { type: 'ul', items: [ - 'Sync engine: checkpoints its page token and message count after every page, so a killed sync resumes rather than restarting; incremental syncs use Gmail\'s historyId watermark instead of re-scanning; all calls run through Polly retry/backoff.', - 'Pagination: keyset (cursor) pagination on large result sets — O(page size) instead of OFFSET\'s O(page × page size) — alongside plain offset pagination for the general browse path.', + "Sync engine: checkpoints its page token and message count after every page, so a killed sync resumes rather than restarting; incremental syncs use Gmail's historyId watermark instead of re-scanning; all calls run through Polly retry/backoff.", + "Pagination: keyset (cursor) pagination on large result sets — O(page size) instead of OFFSET's O(page × page size) — alongside plain offset pagination for the general browse path.", 'AI is advisory only by contract: every AiService method carries the explicit guarantee that it never archives, deletes, labels or unsubscribes on its own.', 'Security is self-audited on a paper trail: a dated, severity-coded internal audit (plaintext-storage risk, missing rate limits, unencrypted keys) with each finding resolved via its own tracked PR and new tests, not just prose claims.', ], @@ -238,17 +238,18 @@ export const inboxintel: Project = { decisions: [ { n: 1, - choice: 'Fuse lexical and semantic search with Reciprocal Rank Fusion, degrading silently on failure.', + choice: + 'Fuse lexical and semantic search with Reciprocal Rank Fusion, degrading silently on failure.', alternative: 'Run semantic search as a separate mode the user has to switch to.', rationale: - 'Keyword search alone misses paraphrased or vaguely-remembered mail; semantic search alone drops exact-match precision. RRF blends the top 50 of each into one ranked list automatically, and if Ollama is down or an email hasn\'t been embedded yet, the query still returns lexical results instead of erroring. Outcome: one search box that works whether or not the AI layer is healthy.', + "Keyword search alone misses paraphrased or vaguely-remembered mail; semantic search alone drops exact-match precision. RRF blends the top 50 of each into one ranked list automatically, and if Ollama is down or an email hasn't been embedded yet, the query still returns lexical results instead of erroring. Outcome: one search box that works whether or not the AI layer is healthy.", }, { n: 2, choice: 'Encrypt OAuth refresh tokens at rest and never log them.', alternative: 'Store them as plain columns — simpler, faster to ship.', rationale: - 'Refresh tokens are long-lived keys to someone\'s mailbox. They\'re encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — flagged in the internal audit as the single most important security decision in the app, and verified clean on re-review.', + "Refresh tokens are long-lived keys to someone's mailbox. They're encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — flagged in the internal audit as the single most important security decision in the app, and verified clean on re-review.", }, { n: 3, @@ -268,7 +269,7 @@ export const inboxintel: Project = { type: 'ul', items: [ 'Google-OAuth gated; Gmail scopes are read/modify only — no send scope is ever requested.', - 'Embeddings and summaries are generated locally (Ollama) by default; an optional cloud fallback exists but mail never leaves the box unless that\'s explicitly enabled.', + "Embeddings and summaries are generated locally (Ollama) by default; an optional cloud fallback exists but mail never leaves the box unless that's explicitly enabled.", 'Polly retry/backoff against Gmail rate limits; Serilog structured logging that never records tokens; OpenTelemetry instrumentation wired for production observability.', 'Clean Architecture keeps the layers testable; 39+ unit and integration tests, including live-database tests for the full-text/fuzzy/pgvector paths.', ], @@ -419,7 +420,8 @@ export const inboxintel: Project = { decisions: [ { n: 1, - choice: 'Fusjonere leksikalsk og semantisk søk med Reciprocal Rank Fusion, med stille degradering ved feil.', + choice: + 'Fusjonere leksikalsk og semantisk søk med Reciprocal Rank Fusion, med stille degradering ved feil.', alternative: 'Kjøre semantisk søk som en egen modus brukeren må bytte til.', rationale: 'Nøkkelordsøk alene bommer på omskrevet eller vagt husket e-post; semantisk søk alene mister presisjon på eksakte treff. RRF blander de 50 beste fra hver til én rangert liste automatisk, og hvis Ollama er nede eller en e-post ikke er bakfylt ennå, returnerer spørringen fortsatt leksikalske resultater i stedet for å feile. Resultat: én søkeboks som fungerer uansett om AI-laget er friskt.', @@ -433,7 +435,8 @@ export const inboxintel: Project = { }, { n: 3, - choice: 'Kreve obligatorisk forhåndsvisning før enhver destruktiv handling, håndhevet to ganger.', + choice: + 'Kreve obligatorisk forhåndsvisning før enhver destruktiv handling, håndhevet to ganger.', alternative: 'Stole på klientsidebekreftelse og utføre ved forespørsel.', rationale: 'Et revisjonsfunn viste ratebegrensnings- og valideringshull rundt massehandlinger tidlig. Løsningen — forhåndsvis-så-bekreft sjekket både på validator- og tjenestenivå, pluss en SSRF-vakt på hver utgående avmeldingsforespørsel — lukket funnet og ble levert med regresjonstester som beviser 400 ved ugyldig input.', diff --git a/site/src/data/projects/jobtrack.ts b/site/src/data/projects/jobtrack.ts index 3c96cc5..0b1f51c 100644 --- a/site/src/data/projects/jobtrack.ts +++ b/site/src/data/projects/jobtrack.ts @@ -313,7 +313,8 @@ export const jobtrack: Project = { tldr: { what: 'Fullstack arbeidsrom for jobbsøking: pipeline, deterministisk CV-match, korrespondanse fra flere kanaler, AI-utkast.', why: 'Et ekte problem — min egen jobbsøking trengte skikkelig verktøy, ikke et regneark.', - stack: 'Next.js + ASP.NET Core (EF Core) · FastAPI/Ollama lokal AI · Gmail/Microsoft/IMAP · Docker.', + stack: + 'Next.js + ASP.NET Core (EF Core) · FastAPI/Ollama lokal AI · Gmail/Microsoft/IMAP · Docker.', role: 'Alt: produkt, backend, frontend, drift og sikkerhet.', }, sections: [ @@ -412,7 +413,8 @@ export const jobtrack: Project = { { n: 2, choice: 'SSRF-sikre jobb-import-URL-henteren før den ble sluppet.', - alternative: 'Stole på den limte inn URL-en og hente den direkte — det er jo bare en stillingsannonse.', + alternative: + 'Stole på den limte inn URL-en og hente den direkte — det er jo bare en stillingsannonse.', rationale: 'Å la brukere lime inn en vilkårlig URL for server-side skraping er en klassisk vei inn i intern infrastruktur. Henteren slår opp vertsnavnet via DNS i stedet for å stole på den bokstavelige verten, og avviser så den oppløste IP-en mot hele det private/reserverte adresseområdet (RFC 1918, CGNAT, link-lokal, IPv6 unique-local) for både IPv4 og IPv6. Resultat: uavhengig verifisert som fikset i en påfølgende sikkerhetsgjennomgang.', },