Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-signer-monorepo

    Public branded quote/estimate document at /{locale}/q/{token}

    The customer-facing surface for the commerce quote system. Server-rendered so the org's branding is in the first paint — an unbranded flash on a commercial document is exactly what this must not do, and it is why the page emits brand custom properties inline via QuoteBrandVars rather than swapping them after hydration. Lives in kamo-signer because that app already has the HostRegexp ingress, autocert coverage, DNS aliases and the theme package with hostnameOverride — and because quote -> accept -> e-sign is then one hop, since EsignGenericEnvelope binds any record via (context_type, context_id). Reusing it was only viable once the /{locale}/ i18n work landed; all 31 strings are translation keys sourced from the dictionary. Money and dates format through next-intl using the quote's own currency and the server-resolved timezone (recipient member -> org -> UTC), never the viewer's device. An expiry rendered in the wrong zone can name a date on which the offer is already dead. Adds the platform's first real @media print block. "Print Invoice" in kamo-internal has always printed the whole app chrome because no @media print rule existed anywhere; here the same markup prints as a clean Letter document with the brand accent preserved (print-color-adjust: exact — a colourless quote reads as a draft), the action bar suppressed, and page-break-inside: avoid on rows and totals. Terminal states are deliberately asymmetric: EXPIRED gets an honest branded page naming the date and the org's contact details, safe because the caller already proved possession of a 256-bit token; unknown/revoked/wrong-org all collapse to one generic page so the surface cannot be probed. The client whitelists the reason before rendering. The token never travels onward in a URL — the server-side resolve posts it in a body, and the BFF forwards the real client IP explicitly because APIService stamps X-Forwarded-For from its own inbound request, which would otherwise put every viewer in one rate-limit bucket keyed to the pod IP. Adds SECURITYSERVICE_URL to the configmap (verified: kamowssecurity-service exists in the kamo namespace).

  2. · SecurityService

    Quote REST surface — staff management and public share links

    Staff endpoints at /api/security/commerce/quotes and an unauthenticated resolver at /api/security/public/quotes. QuoteController extends BaseLosController rather than copying POSController's preamble: CallerSessionResolver accepts the X-OTK attribute, the X-***-Token header AND the *** cookie, where POSController's private getOrgId reads only the OTK attribute. Every handler is rights-gated (VIEW/MANAGE/SEND_QUOTES). Most of the commerce surface is session-only — any authenticated member of any org can mutate orders — and that is deliberately not repeated here, because a quote commits the org to a price in front of a customer. QuotePublicController needs no security-config carve-out: the Spring chain is anyRequest().permitAll() and OTKPreAuthFilter only acts on an X-OTK header, which a browser following an emailed link never sends. Design points: - The token arrives in the POST body, never a URL. APIApp.forward() logs request lines, and a bearer credential in a path would land in those logs. - Not an enumeration oracle: unknown, revoked and org-mismatched tokens all return one indistinguishable generic failure. Only EXPIRED gets a specific, honest answer, which is safe because the caller already proved possession of a 256-bit token. - The org derived from the request host is cross-checked against the org on the link row. Without it, a valid link for org A opened under org B's host would render A's pricing wearing B's branding — a phishing primitive. - no-store / Referrer-Policy: no-referrer / X-Robots-Tag: noindex on every response. The page loads logos from theme.<org>, and a Referer would carry the token off-origin. PublicLinkRateLimiter is Redis-backed because SecurityService is multi-replica — in-process counters would be a limiter in name only. It is explicitly DoS and log-hygiene protection, NOT the anti-guessing control: 256 bits of entropy is. Failures are counted far more harshly than successes, since one office opening a forwarded quote fifty times is normal while fifty misses is not. It fails open, so a Redis outage cannot take the quote page down. QuoteEmailClient reuses the EmailService transactional path (X-Internal-Auth, QUOTE_SENT template) and sends only the headline figure plus the link — never the itemised pricing, so a forwarded email is not itself the disclosure. Pre-existing test failures unchanged by this commit (verified by stashing this work and re-running on clean HEAD): **************** NPE on a null EntityManager, and the legacy com.retval SecurityServiceApplicationTests missing a @SpringBootConfiguration. REQUIRES a KamoInitializerService run before these endpoints work: the quote tables are new and ddl-auto is none here, so the service boots normally and only the quote endpoints 500 on a missing relation until KI has run.

  3. · kamo-shared-library

    Quote/estimate domain — entities, pricing engine, share links

    Adds a first-class Quote alongside the existing commerce spine. The pre-existing "ServiceQuote" is an Intent plus a JSON blob carrying one offering and one flat amount, with no recipient, no expiry and no UI; it is not a foundation. Extending Intent directly was rejected too: commerce_intents is shared by LOAN_APPLICATION, RENTAL_REQUEST, SUBSCRIPTION_SIGNUP and PURCHASE_REQUEST, so ~25 quote columns would put permanent NULLs on the mortgage and rental paths. Entities **************** Quote engagement-linked, versioned, valid_until + valid_for_days QuoteLineItem snapshots name/sku/price so a sent quote never re-prices QuoteShareLink bearer credential, SHA-256 at rest, revoke/expiry/view counts QuoteLinkView per-open audit (ip, user-agent) QuoteEvent append-only lifecycle trail Totals are server-authoritative. QuoteTotals is the single writer of every money field and the request DTOs carry no totals at all — deliberately unlike createRetailOrder, which takes subtotal/tax/total verbatim and can be handed a populated order with total = 0. Line discounts apply before the quote-level discount, discounts can never exceed their base, intermediate maths stays at scale 4 and only the grand total rounds to the currency's minor unit. Share tokens: 32 bytes of SecureRandom as 43 base64url chars (256 bits). Same entropy as the e-sign signer token but 21 chars shorter, keeping the emailed URL under the 76-char plain-text wrap limit. Only the SHA-256 is persisted. Tax is honest rather than invented: the platform has no tax engine (TaxClass/ TaxZone/TaxRate are authorable and read by no calculation), so ENGINE mode reports NOT_CONFIGURED instead of fabricating a figure on a commercial document. QuoteLineItem.taxClassId is UUID, matching TaxClass's real PK — Offering.taxClassId is a Long against a UUID-keyed table and is structurally unresolvable. Also: rights 178-180 (VIEW/MANAGE/SEND_QUOTES) and the QUOTE_SENT canonical email template, registered in CANONICAL_KEYS (absent there means a permanent 404). 35 tests covering the pricing engine and token hygiene. Full suite: 896 passing. Schema note: these are new tables. KamoInitializerService must run before SecurityService serves the quote endpoints — ddl-auto is none there, so the service boots fine and the endpoints 500 on a missing relation until it does.

  4. · KlusterServices

    Egress containment for the avatar resolver, as a CiliumNetworkPolicy

    Reinstates what 8f6c672 reverted, in the only form that actually works here. The earlier attempt failed because a plain NetworkPolicy cannot express this cluster's topology. Three of SecurityService's four critical dependencies live on the node, not in the pod network — cockroachdb-0 and nats-k1m1-0 run hostNetwork, and MinIO is a host process — so a podSelector can never match them. The non-obvious part, and the reason the first patch attempt also failed, is that an ipBlock for the node subnet does not fix it either: Cilium classifies the node as the reserved host/remote-node identity, and CIDR rules deliberately do not match reserved identities. toEntities is the only construct that expresses it. Both failure modes were reproduced against a throwaway canary pod before writing this, rather than against production. The canary carried the same rule set under a distinct label, which is how the ipBlock version was caught silently blocking CockroachDB, NATS and MinIO while looking perfectly reasonable. Verified on the canary, then confirmed on SecurityService with no restarts and no connection errors: ALLOW dns, crdb:26257, nats:4222, minio:9000, redis:6379, siblings:80, world:443 BLOCK node:22, node:443, kube-api:443, redis-via-node:6379, 169.254.169.254, loopback:9000 The last four are the point: an SSRF in the resolver now has nowhere to go. The in-code guard remains the primary control — it is the only layer that understands DNS rebinding — and this is defence behind it. Header carries the canary procedure. Anyone changing this should re-run it rather than reason about it; Cilium's identity model does not behave the way plain NetworkPolicy intuition suggests, and there is no staging environment.

  5. · kamo-internal

    Show a facepile for group conversations

    A thread with five people on it was represented by one arbitrary face, which reads as a message from one person. Up to three participants now overlap, so "several people" is legible at a glance without needing to parse the name line. Capped at three: past that the circles shrink below recognisability and the row becomes texture rather than information, and the remainder is already stated in the "and N others" text right beside it. The reader is filtered out of their own thread list, and the other party leads — faces read left to right, so the primary correspondent has to be the leftmost one and the one carrying the unread badge. Each face gets a ring in the row background colour; without it a stack of similar photos merges into one shape. Resolution now covers the first few participants rather than only the lead, otherwise the second and third faces would sit as monograms permanently.

  6. · SecurityService

    Instrument the chain, reap expired entries, record disclosures

    Every failure mode in this feature is silent. A latched circuit breaker, a worker queue discarding every task, a provider that started returning HTML — each looks exactly like "this correspondent has no photo", which is also what correct behaviour looks like. There is no user-visible difference between working and completely dead, so it has to be measured. Metrics (Prometheus was already wired up, so this is mostly naming): cache hit ratio, chain duration tagged by the tier that answered, per-provider hit/miss, worker queue depth, and a discarded-task total. That last one previously used DiscardPolicy, which drops silently by design; it now increments a counter, and the counter is declared before the executor because field initializers run in source order and relying on lazy capture would break on a harmless reorder. Disclosures go to their own logger. Only the identity tier is a personal disclosure — the domain tiers transmit the right-hand side of an address, which identifies no one — and it is recorded before the call rather than after, so the trail exists even when the request then fails. The address is written in full and deliberately: the point is to answer "who was this person's data shared with", and a hash cannot answer that. The reaper closes a leak: deleteExpiredBefore existed and nothing called it, so sender_identity grew forever. A busy mailbox touches thousands of correspondents once each and leaves a negative row behind for every one. Tests cover the fetcher against a real socket, because the interesting cases are all provider behaviours rather than logic: a 404 carrying a valid image (DuckDuckGo), a soft-404 serving HTML, an origin understating Content-Length, a 1x1 stub, and a redirect aimed into the pod network. The production client cannot be pointed at a loopback test server — its DNS pin refuses to resolve anything private, which is the whole point — so those run against a relaxed client and the pin is asserted separately.

  7. · kamo-internal

    Promote an ingested LLPA matrix into adjustments from the console

    The ingestion panel could only publish a rate sheet. For an LLPA_MATRIX source document it now shows a "Promote as LLPA adjustments" card: pick the existing rate-sheet version the LLPAs adjust (+ an optional group), and each approved FICO/LTV bucket becomes a conditional price adjustment. The result names how many were authored and, honestly, how many rows were skipped because a bucket couldn't be read safely or was already present. ingestionApi.promoteAdjustments posts to the new gateway relay.

  8. · SecurityService

    Gateway relay to promote an LLPA matrix into conditional adjustments

    **************** gain promote-adjustments, mirroring the promote-rate-sheet relay: god-eligible gate, organizationId server-stamped onto the payload via write(), downstream 4xx relayed verbatim. Lets a steward turn a reviewed LLPA matrix into real per-scenario price adjustments from the console.

  9. · VOIPService

    Record call recording, voicemail and transcript access

    Recording playback left no trace. A recorded call is the densest PHI the platform stores — the unredacted conversation — and "who listened to this call" was unanswerable, which is the first question a §164.402 breach assessment asks. Wires CallRecordingAuditor into every read that discloses it: - GET /recordings/{id}/audio CALL_RECORDING / DOWNLOAD - GET **************** CALL_RECORDING / DOWNLOAD - GET /recordings/{id} CALL_RECORDING + TRANSCRIPT / VIEW - GET **************** CALL_RECORDING / DOWNLOAD - GET /instances/{id}/voicemails TRANSCRIPT / LIST, per row The transcript is the one that hides. It is not a media download; it rides inside the metadata JSON and inside every voicemail row, and it is the same PHI as the audio in a form that greps. It gets its own event because an accounting of disclosures is assembled per resource. Byte serving fails closed, so PhiAuditAvailabilityAdvice turns an unloggable read into a 503 rather than a 500 — the request was fine, we decoderd to serve PHI we could not account for. Refusals are recorded too; probing is what detection looks for. Also closes an authorization gap found while wiring this: POST /recordings/lookup took an instanceId from the request body and never checked the caller's org owns it, so it resolved another tenant's provider call ids to their recording ids. Every other instance-scoped endpoint in the service already goes through findByIdAndOrgId; this one now does too. Recording playback authorization is left as-is and documented in place: org ownership is the only check, so any member can play back any recording in their org. Narrowing it means deciding whether "own extension" is the boundary and which right lifts it — that is a product decision, not a guess to make here, and every access is at least recorded now.

  10. · AIService

    Record PHI access on every read of stored AI conversation history

    Wires the shared §164.312(b) spine into the paths that read persisted prompts and completions. The assistant is the module the compliance assessment put OUTSIDE the boundary — prompts fan out to external providers under no BAA and are then kept indefinitely — so reads of that store are the disclosures most in need of a trail, and they had none. Audited: the session list and history search (one event per conversation, not one per request), a single conversation's metadata and its messages, the public widget reading its own transcript back, and the handoff bundle. Refusals are recorded too — probing another org's guids is what detection looks for — and an unauthenticated attempt is attributed to the sentinel org rather than dropped, since the recorder rejects what it cannot attribute and that is the event most worth keeping. The handoff bundle is a DISCLOSE, not a VIEW: it carries a visitor's conversation out of AIService and in front of a human in another product. That kind fails closed, so a lost record now withholds the transcript and answers 503 rather than serving data it cannot account for. Identifiers only. Never a message body, a title, or the search term — a member searching their assistant history types the borrower's name into it, and an audit trail that reproduced the prompt would just have moved the PHI into the log stream. Completes d428ee3, which a concurrent session swept up mid-change: the auditor was missing recordDeniedInternalView, and PublicAiChatController's new constructor argument had not reached its test.

  11. · MediaService

    Record chat message reads on the endpoints that return bodies

    Sending a message left a row; reading one left nothing. Every chat, support and social thread on the platform renders from a handful of endpoints here, and none of them recorded anything, so "who read this conversation" had no answer — which is the first question a 164.402 breach assessment asks. ChatMessageAccessAuditor emits a PhiAccessEvent (CHAT_MESSAGE) from the five paths that hand a caller message text: GET **************** LIST GET /api/media/sessions/chat LIST POST **************** VIEW GET **************** LIST POST **************** VIEW One event per message, not one per request. "They opened the thread" is not an answer to "was this individual's message disclosed", and the message ids are already in hand at every call site, so a request-shaped row would have been a choice rather than a limitation. The thread read records AFTER the mute filter, so a row means a body that caller was actually handed. The chat list is in scope because each row carries up to 140 characters of the thread's last message — that is content, not metadata. Rows with an empty preview record nothing. Deliberately NOT recorded: unread counts, presence, last-seen, typing, read receipts, session-member lists, mutes, ticket detail (metadata plus status history, no bodies) and support reporting aggregates. A trail is only useful if a row means someone saw content; rows that stand for nothing bury the ones that do. Refusals are recorded too, against the thread guid — no message id exists yet at the point of refusal, and repeated refusals against thread guids is the shape probing has. An unauthenticated reader is attributed to a sentinel org rather than dropped: the recorder rejects unattributable events, and that is the single most interesting event this trail can hold. Client IP comes from X-Forwarded-For with remoteAddr as fallback — everything arrives via Traefik, so remoteAddr alone records the ingress pod. Public chat takes a second hop through APIService, which sets X-Forwarded-For to the visitor address and moves the browser's User-Agent to X-Original-User-Agent, so the auditor prefers that header; the literal User-Agent on that hop is APIService's own HTTP client and identifies nobody. Visitor reads are attributed to the org from X-Public-Chat-Org-Id with no member id: a visitor has no member identity of their own, but the org owns the records, so the read stays attributable. Identifiers only, never message text. An audit trail that reproduces the message it audits has just copied the PHI into the log stream. Also completes eed3817, which swept this work in mid-edit and left MediaController referencing PhiAccessKind before the import existed — that revision does not compile. This one does: mvn -o test is 16/16 green.

  12. · DocsService

    Record byte access on the four document paths that recorded nothing

    /download already logged through **************** and it was the platform's only byte-access injection site. The sibling endpoints serving the exact same bytes — /stream, /bulk-download, the template converted-PDF and the template page thumbnail — recorded nothing, so anyone wanting document bytes without leaving a trace only had to pick a different URL. All four now emit a PhiAccessEvent at the point access has been verified and the bytes are about to be served. Bulk download emits one event per document rather than one per request. "They exported 400 documents" is not an answer to "was this individual's record disclosed", and per-document rows are what make that answerable six years later under §164.528. Client IP comes from X-Forwarded-For with remoteAddr as fallback: every request arrives through Traefik, so remoteAddr alone would record the ingress pod for every access and be worthless for detection. An unauthenticated attempt is attributed to a sentinel organization rather than dropped — the recorder rejects unattributable events, and a request for document bytes with no session is exactly the event worth keeping. This sits alongside the existing ImgLogDownload rows rather than replacing them: those are imaging-domain records, this is the cross-surface trail an accounting of disclosures is assembled from. Note ImgLogService, which reads those rows, still has zero callers anywhere in the platform.

  13. · KlusterServices

    Contain egress from the avatar resolver; note the Redis eviction pool

    SecurityService now runs a fetcher whose target URL is chosen by an untrusted party — a BIMI record is DNS content published by whoever owns a sending domain, and anyone becomes a sending domain by emailing a tenant. Nothing in this cluster constrained where such a request could go: Redis answers unauthenticated on 6379, MinIO on 9000 with root credentials, CockroachDB on 26257, and the node metadata endpoint is reachable from any pod. The resolver enforces its own controls in code, and those stay the primary defence because they are the only layer that understands DNS rebinding. This is the second one, so a bug there still cannot reach a private address. Deliberately allow-most rather than allow-list. These services legitimately reach hosts, so enumerating destinations would be unmaintainable and would break quietly. Instead it permits the public internet with every private range excepted, then re-permits the specific in-cluster ports actually needed. Scoped to the pods that perform untrusted fetches — applying it namespace-wide would cut off services whose egress has never been audited. The Redis change is a comment and a databases bump, not a behaviour change. It records why the sender-avatar cache stores only short metadata strings: maxmemory 2gb with allkeys-lru evicts across the whole instance, and DB 0 holds every live session, so a bulk byte cache here would sign users out at random under pressure and be misdiagnosed for days.

  14. · kamo-internal

    Show sender and recipient photos

    The mailbox rendered a single letter on a single shared gradient for everyone, so every row looked the same and the circle carried no information. It now shows the correspondent's actual photo when one is known, and a two-letter monogram when it is not. One SenderAvatar replaces seven hand-rolled initials circles. That mattered for more than consistency: each carried its own idea of how to split an address, and the avatar cache is keyed on that split, so any disagreement between the list and the reading pane would silently halve the hit rate and show the same person two different ways on one screen. parseAddress is now the only place an RFC 5322 address is taken apart. The list and thread views also stop printing raw headers like "Jane Doe <jane@acme.com>" and show the name. Identity lives in its own store keyed by address, never on the message objects. Both list views poll every 30s and on every websocket notification, each time replacing their message array wholesale — anything merged onto a message would be wiped twice a minute, so an idle inbox would flicker photo to monogram and back forever. Keying by address also means a late-arriving answer cannot paint onto the wrong row, because rows are not what it is keyed by. Requests are coalesced into one batch per screen: fifty rows asking independently would be fifty requests, and since every call to this service mints a one-time key first, actually a hundred. A miss is re-asked once after 45s, because server-side resolution is two-phase and an unknown address is answered only on a later call. ConversationListView is now virtualized. It fetches 50 rows a page and re-rendered all of them inside an AnimatePresence with a per-index stagger on every poll; adding an image to each made it the heaviest surface in the mailbox. No-reply and mailer-daemon senders get a neutral mark rather than a monogram — a face beside a bounce notice is actively misleading. Photos are decorative (alt="") because the name is always visible text beside them, and carry referrerpolicy="no-referrer" so a thread identifier never reaches an image host. Every avatar is served from our own origin — third-party images are fetched, validated and re-hosted server-side — which is what lets the new per-request img-src policy stay pinned to the tenant's own domains. That policy is built in the proxy rather than next.config because this platform is white-labelled: a static policy would have to name one tenant and would break images for the rest. It also closes a pre-existing stored tracking-pixel vector, since Contact.photoUrl is a free-text URL rendered directly in several <img src>. next/image no longer accepts remote SVG. That was an acceptable risk when the only SVGs were our own theme logos; it is not now that the platform ingests marks published in third-party DNS. Contacts callers drop the hardcoded ORG_ID = 1 and the orgId query parameter, matching the backend change that derives tenancy from the session.

  15. · EmailService

    Avatar endpoints; fix unauthenticated reachability and contact tenancy

    Adds the mailbox side of sender avatars, and fixes three pre-existing holes this work would otherwise have built on top of. AUTHENTICATION. OTKPreAuthFilter used to reject a *bad* OTK but treat *no* OTK as anonymous-and-allowed, falling straight through to the filter chain — and the Spring Security chain here is anyRequest().permitAll(). Every mailbox, contact and calendar endpoint was therefore readable by anything that could reach the ClusterIP. Protected prefixes now fail closed. The fix lives in the filter rather than in the security chain deliberately: reshaping the chain would touch thirty unrelated surfaces at once, including two servlets and the DAV endpoints. The exempt list is the complete set of genuinely session-less entry points — the CAN-SPAM opt-out link, the OAuth redirect target where the tenant rides in the signed state parameter, and the server-to-server surfaces that authenticate with X-Internal-Auth. CONTACT TENANCY. Every contacts endpoint took orgId as a query parameter, or reached a book by id with no ownership check at all, so changing a number in the URL read another tenant's contacts — names, addresses, phone numbers, notes. The org now comes from the session and every book- or group-scoped operation proves the target belongs to it. Absent targets return 404 rather than 403 so an id in another tenant is not confirmed to exist. orgId is still accepted on the two list endpoints and ignored, so clients do not break mid-deploy. That change also fixes a silent bug: searchContacts derived orgId from bookId and passed null when no book was selected, into a query whose first predicate is contactBook.organization.id = :orgId. Book-less search therefore always returned nothing — which is exactly how the mail recipient autocomplete calls it. EMAILLOGOSERVICE. It fetched an operator-supplied URL with no scheme or address check, and transcoded the result on a Batik that resolves external references by default. A logo SVG containing <image **************** would have been dereferenced from inside the cluster. Now guarded on both ends. MESSAGEINDEXER. resolveMemberIdFromEmail pulled the entire members table into heap and filtered it in Java, once per indexed message, on a 1Gi pod — the code said so in a TODO. Same semantics, one indexed query. The avatar surface is one batched endpoint rather than a field on the message DTOs. Both list views poll every 30s and replace their message array wholesale, so an avatar carried on a message would be wiped twice a minute and the UI would flicker forever; and EmailMessage is shared by the folder list, reading pane, thread view and search, so changing its shape would touch all of them at once. The directory-photo endpoint is server-to-server only — exposed to a browser it would be a directory-enumeration oracle for the tenant.

  16. · SecurityService

    Sender-avatar resolution chain for the mailbox

    Resolves a correspondent's email address to a picture, working outward from what Kamo already knows: member photo, then a photo someone attached to a contact, then the tenant's connected Microsoft/Google directory, then the sender domain's BIMI mark, then its favicon, then Gravatar. Anything unresolved renders a monogram, so the chain always terminates locally. The directory tier is worth calling out: the connect flows here already request admin.directory.user (Google) and User.ReadWrite.All + Directory.Read.All (Microsoft) alongside the mail scopes, so for Workspace and 365 tenants this returns authoritative photos with no re-consent and no third party involved. It should beat anything on the public internet and usually will. Two phases, never one. A request reads cache only; unknowns go to a bounded worker with DiscardPolicy — running an overflowed task on the caller would hand a request thread to a third-party host, and a discarded resolve costs nothing because the next poll asks again. A full-miss chain is DNS plus four HTTP calls, and a page carries dozens of addresses; nothing about opening a folder may depend on third-party availability. Decoupling also keeps resolution from correlating with a human opening a message, which would make it a read receipt — the exact behaviour the mailbox already blocks by default for remote images. This is the platform's first fetcher whose target URL is chosen by an untrusted party: a BIMI record is DNS content published by whoever owns a sending domain, and anyone becomes a sending domain by emailing a tenant. There is no egress NetworkPolicy and no forward proxy, so the controls are in code. SafeImageFetcher pins DNS — a custom resolver hands OkHttp only the addresses that passed PublicHostGuard, closing the rebinding window that defeats a naive pre-flight check — refuses redirects, caps bytes as they stream rather than trusting Content-Length, and requires an image content-type. SvgSanitizer parses and re-serializes against an allowlist instead of rasterizing. Batik 1.17 resolves external resources by default, so handing it a hostile SVG would dereference <image **************** from inside the cluster after the URL check already passed. The Tiny PS profile forbidding that is a statement about conforming files, not about bytes fetched from an attacker's URL. Provider miss semantics are all different and all cache-poisoning if treated as hits: Gravatar is asked with d=404 because every other default returns 200 with a placeholder; Google's no-match is a 404 carrying a byte-identical globe, so it is rejected by content hash; DuckDuckGo returns 404 with a real image body, so status decides. Bytes are re-hosted under a random name — never one derived from the address, which would let anyone probe whether a given person corresponds with this platform — and served through an authenticated route. Redis holds metadata only. That instance is 2GB with allkeys-lru on an emptyDir, sharing a keyspace with every live session; caching image bytes there would evict sessions and sign users out at random.

  17. · kamo-shared-library

    Sender identity model + org-scoped batch email lookups

    Backing store and queries for the mailbox sender-avatar chain. SenderIdentityEntity is the durable tier of the avatar cache. org_id is part of the natural key rather than decoration: USERS.EMAIL is unique only per security provider, so an answer derived from tenant data must not be visible to another tenant. External-tier answers (BIMI/favicon/Gravatar) are genuinely global and share a sentinel org so every tenant shares the hit rate. Misses are stored as rows, not absences — without that, every unresolvable sender re-runs the whole provider chain on every inbox poll. OrgAvatarSettingsEntity splits the chain by what actually leaves the building. The identity tier defaults OFF because it discloses a digest of a correspondent's full address to a third party, which on a lending CRM is a decision about a borrower identifier rather than a display preference. The repository additions exist because none of this was reachable before: - ContactEmailRepository had no lookup by address at all — only substring LIKE for the autocomplete — so a contact that had a photo could not be resolved from a From: header. - Member/User batch lookups are org-scoped and take pre-lowercased input. LOWER() is applied to the stored column only; emails are persisted verbatim (EmbEmail has no normalizer) and CockroachDB equality is case-sensitive. - The avatar-id projections return ids rather than entities on purpose. Both Member.avatar and User.avatar are lazy associations onto a JOINED hierarchy: a caller outside a transaction gets LazyInitializationException, and one inside gets a base-type proxy that never narrows to AvatarPhoto, putting the file extension needed to build the URL out of reach. - **************** replaces a findAll() full-table scan that ran once per indexed message. PublicHostGuard is shared rather than duplicated because it is a security control: it decides whether an address is somewhere we will send a request that an untrusted party chose. Tested against the real bypass techniques — IPv4-mapped IPv6, 6to4, NAT64, CGNAT and link-local metadata. Right 177 gates the disclosure decision.

  18. · kamo-shared-library

    Audit spine for PHI access — §164.312(b)

    The Security Rule requires recording access to ePHI, which means reads. The platform records changes well — LeadHistory, SystemAccessLog, UserHistory, LoanAuditEvent — and reads almost nowhere, which is why "who viewed this record" is currently unanswerable. That is also the first question a §164.402 breach assessment asks, so this one gap blocks both the audit control and the four-factor analysis. Capture layer only; no schema. The durable store sits behind PhiAccessLogWriter so call sites can be wired now and the sink can evolve — plain table, then append-only with a hash chain, then a signed export to object-locked storage — without rippling back through hundreds of call sites. Two properties carry the weight: - Failure handling belongs to the access kind, not the caller. EXPORT, DOWNLOAD and DISCLOSE fail closed: an unlogged bulk export is exactly the event you cannot reconstruct afterwards, and "we served the data but lost the record" is the worst answer to give an investigator. VIEW, LIST and SEARCH fail open and log loudly, because losing one line is not worth taking the product down. That asymmetry is deliberate and wants signing off rather than assuming. Callers under pressure resolve policy questions in whichever direction unblocks them, so the policy is not at the call site. - A denied read is still a read. Refused attempts are recorded, because the access pattern of someone probing records they cannot reach is what detection needs and is invisible if only successes are logged. Startup safety, which nearly went wrong: PhiAccessRecorder is a @Component and every service component-scans com.kamo, so a hard dependency on a writer bean would have failed context startup across ~40 services at once — a whole-platform outage from a feature nothing had called. The first fix, a @Configuration with @ConditionalOnMissingBean, does not work: component-scanned configuration is processed alongside user configuration rather than after it, so both writers register and injection fails on ambiguity. PhiAuditContextWiringTest caught that. The fallback now lives in the recorder via ObjectProvider.getIfUnique, where no ordering question exists, and ambiguous writers degrade to the default rather than failing startup. The default Slf4j writer is a starting point, not the destination — log lines are not tamper-evident and retention is 14 days against a six-year obligation. It exists so call-site wiring does not wait on the store. Identifiers are logged, content never is: an audit trail that reproduces the record it audits has just moved the PHI into the log stream.

  19. · SecurityService

    403 rather than 500 when the compliance boundary refuses

    Two call sites of featureService.enableFeature now understand the HIPAA boundary: - /enable maps PhiModuleNotPermittedException to 403 carrying the reason, mirroring the AppliedModelViolationException handling directly above it. Without this the broad catch below turned a deliberate policy refusal into a 500. - The auto-enable loop in the listing path pre-checks with isPhiPermitted and skips. It runs over every completed ServiceType while merely *listing* an organization's features, so throwing on the first blocked module would have made the features page unloadable for a PHI tenant instead of hiding one entry. Inert today — every organization is handlesPhi=false.

  20. · kamo-shared-library

    Enforce the compliance boundary at the feature switch

    Wires PhiTenantGuard into FeatureService, the single choke point through which a module is switched on for an organization. This is where the boundary stops being a policy object and starts being enforcement. Both halves, because either alone leaves a gap: - enableFeature throws PhiModuleNotPermittedException, so a PHI tenant cannot switch on a module Kamo cannot stand behind as a business associate. - isFeatureEnabled(Organization, ServiceType) reports a module disabled whenever the tenant handles PHI and the module is outside the boundary, whatever the OrgFeature row says. The row can legitimately say active — it was enabled before the tenant became a PHI tenant, and enableFeature does not run again on that transition. isPhiPermitted is the non-throwing form for callers that iterate. FeatureController auto-enables every completed feature while *listing*, so a throw on the first blocked module would fail the whole features page rather than skip one entry. PhiServiceTypeMapping binds ServiceType to PhiModule and is total, with a test that fails the build on any unmapped value. That test earned its keep on first run: it caught ServiceType.STORAGE, which a grep of the enum had missed, and then AI_CHAT, DOC_MANAGER, DOCS_EDITOR and DOC_CLASSIFIER. Without it, five service types would have been silently outside the boundary — the same omission failure PhiModule's constructor closes, reintroduced one layer up. New modules for the previously unmapped service types, each with an explicit disposition: object storage, document editor and classifier, support tickets, HR, inventory, legal, calculator and activity feed are inside; clubhouse and games are BLOCKED_PENDING_WORK on the grounds that they have not been assessed, which is a reason to block rather than to assume. Every organization is handlesPhi=false today, so this changes no behaviour in production. It becomes load-bearing the moment the first tenant is flagged.

  21. · kamo-shared-library

    Auto-assign a MERS-compatible loan number at application creation

    LoanFile.loanNumber was null on create (both blank-create and lead-promote) and only ever set when a steward typed one — which hard-blocked MERS MIN minting (a MIN's 10-digit segment IS the loan number) and every downstream identifier for a headless-originated loan. AppMortgageService now assigns a loan number at both creation sites: a clean 10-digit numeric (MinMinter strips non-digits and needs a <=10-digit segment, so a pure numeric is the only safe shape), unique per org via the (org_id, loan_number) index with a bounded retry. It FAILS OPEN to null on a collision streak or an unavailable repo — degrading to the prior manual behavior rather than ever breaking application creation. A lender-supplied number still overrides via the loan-file patch path. No schema change (the column already exists, indexed).

  22. · kamo-signer-monorepo

    Render signer dates in the recipient's resolved timezone

    SigningSession gains a required `timeZone` (IANA id) that ESigService resolves from the recipient's member zone, then the org's, then UTC. WelcomeScreen now passes it explicitly to the formatter, so the expiry date depends on neither the viewer's device settings nor the provider's UTC default. Making the field required rather than optional caught the one literal session construction site (preview.ts) at compile time. Also: - call setRequestLocale in the locale layout so translations bind to the URL segment rather than relying on the middleware header alone - assert in the Dockerfile that messages/ survived `turbo prune`, and document why .dockerignore must keep .gitignore out of the build context; without that coupling prune drops the translations and the image ships rendering raw keys with nothing failing until a signer opens the document

  23. · ESigService

    Resolve signer display timezone from member, then org

    Signing dates were rendered in whatever zone the client defaulted to, so an envelope expiring 2026-08-03T05:00Z could read "Aug 3" to a Pacific signer for whom the link is already dead on the evening of Aug 2. SigningSession now carries a server-resolved IANA timeZone: the recipient's own Member.timezone when they are a member and have set one, else the sending Organization.timezone, else UTC. SignerZone is a pure resolver with tests, including one that asserts every TimezoneType maps to its own zoneId so a new enum value cannot ship a client-side RangeError. Also fixes SigningSession.locale, which emitted the enum name ("SPANISH") rather than the BCP-47 code ("es") used in the signing URL's locale segment.

  24. · InitializerService

    Create HANDLES_PHI and PHI_EFFECTIVE_DATE on orgs

    Mirrors the two new Organization fields, following the OrgBrandingFieldsMigration convention, so the columns are reproducible on any environment rather than only existing where a ddl-auto pass happened to run. HANDLES_PHI is NOT NULL DEFAULT FALSE so existing tenants are explicitly outside the compliance boundary. PHI_EFFECTIVE_DATE is TIMESTAMP, not TIMESTAMPTZ, because the entity field is a LocalDateTime. The test is a drift guard, not a restatement: it reads column names off the entity by reflection and fails the build when an entity field has no ALTER. That is this convention's actual failure mode — someone adds a field, forgets the migration, and with ddl-auto:none in the services it surfaces as a production query error instead of at build time. Verified by mutation: removing the PHI_EFFECTIVE_DATE ALTER makes it fail with the actionable message, restoring it goes green. Both columns are already present in the cluster; these ALTERs are idempotent.

  25. · kamo-shared-library

    Persist handlesPhi and phiEffectiveDate on Organization

    Backs PhiTenantGuard with a real column. HANDLES_PHI is NOT NULL DEFAULT FALSE following the **************** pattern, so every existing tenant is explicitly outside the compliance boundary rather than silently inside it. PHI_EFFECTIVE_DATE scopes a breach assessment and a retention obligation to the right window — "since when" is the first question asked after "what was exposed". Convert to policy with **************** The guard itself stays free of the entity, so a caller holding only a session-cached flag does not have to load a row. Safe to push: both columns already exist in the cluster and every one of the 12 organizations reads false. Verified against information_schema before landing — handles_phi boolean NOT NULL DEFAULT false, phi_effective_date timestamp NULL. See the KamoInitializer migration for the reproducible form.

  26. · kamo-shared-library

    PhiTenantGuard and the module compliance boundary

    The keystone of the HIPAA compliance boundary: which modules a tenant may reach, given whether that tenant handles protected health information. Everything downstream keys off it — which integrations are disabled, whether MFA is forced, which sessions get the stricter timeout, what the audit spine must cover. Carries no schema dependency. An earlier attempt hung this off a new Organization.handlesPhi column and had to be reverted: service CI clones shared-library main at build time while KamoInitializer is run by hand, so an entity column on main breaks the next service deploy until someone migrates. Decoupling the policy from the entity means it lands and can be enforced now, and the column becomes an independent step. Three properties the design is built around: - Fails closed. PhiModule's constructor requires a PhiDisposition and a reason, so a module added later cannot inherit permission by omission — it will not compile without a decision, and anything other than PERMITTED denies. That is the failure mode a compliance boundary has to survive: a module ships, nobody thinks about PHI, a clinic starts using it. - An unresolved tenant is not a safe tenant. The guard takes a tri-state PhiTenantStatus rather than a boolean, so "I could not load the organization" cannot be spelled the same way as "this organization is not PHI". With a boolean, the error path is isPermitted(false, …) — easy to reach for, impossible to spot in review. UNKNOWN denies everything. - Nothing to elevate. The decision is a pure function of (status, module) with no caller, session, right or god-mode parameter. "Our support staff can turn it off" is the answer that fails a covered entity's review. Dispositions follow the scoping analysis in kamo-internal **************** Permitted: CRM, contacts, document vault, self-hosted email, e-sign, notes, tasks, calendar, internal chat, the in-cluster transcription/translation stack, MLOS. Blocked with no obtainable BAA: social messaging, Canva, e-commerce sync, MCP gateway. Blocked pending named work: the AI router, RAG search, meeting recordings, VOIP recordings, marketing campaigns, analytics export. Nothing calls the guard yet, so this is inert until wired.

  27. · InitializerService

    Create HANDLES_PHI and PHI_EFFECTIVE_DATE on orgs

    Mirrors the two new fields on the Organization entity in kamo-shared-library, following the OrgBrandingFieldsMigration convention. HANDLES_PHI is NOT NULL DEFAULT FALSE so every existing tenant is explicitly outside the compliance boundary rather than silently inside it. PHI_EFFECTIVE_DATE is TIMESTAMP, not TIMESTAMPTZ, because the entity field is a LocalDateTime. SEQUENCING — this needs to run before the next service deploy. Services pin kamo-shared-library at a fixed version that is republished in place, so the first service to rebuild after that library change starts selecting these columns, and with ddl-auto:none in the services that fails until this runner has executed. The accompanying test is a drift guard, not a restatement: it reads the column names off the entity by reflection and fails the build if an entity field has no ALTER. That is the failure mode this whole convention has — someone adds a field, forgets the migration, and it surfaces as a production query error instead of at build time. Verified by mutation: removing the PHI_EFFECTIVE_DATE ALTER makes it fail with the actionable message, restoring it goes green.

  28. · ESigService

    Emit the signer locale segment in signing links

    Extracts the byte-for-byte duplicated buildSignLink into SignLinkBuilder and adds a locale segment sourced from Organization.language, falling back to English for the four LanguageType values without translations. Requires the kamo-signer /{locale}/ routing to be deployed first.

  29. · kamo-shared-library

    Add the handlesPhi tenant flag and PhiTenantGuard

    The keystone of the HIPAA compliance boundary: a per-organization flag plus a guard deciding which modules that organization may reach. Everything downstream keys off it — which integrations are disabled, whether MFA is forced, which sessions get the stricter timeout, what the audit spine has to cover. Organization gains HANDLES_PHI (BOOL DEFAULT FALSE) and PHI_EFFECTIVE_DATE, following the existing **************** pattern. Defaults false, so this is inert for the existing mortgage and CRM book. Three properties the design is built around: - Fails closed. PhiModule's constructor requires a PhiDisposition and a reason, so a module added later cannot inherit permission by omission — it will not compile without a decision, and anything other than PERMITTED denies. That is the specific failure mode a compliance boundary has to survive: a module ships, nobody thinks about PHI, a clinic starts using it. - Not bypassable. The decision is a pure function of (organization, module) with no caller, session, right or god-mode parameter, so there is nothing to elevate. "Our support staff can turn it off" is the answer that fails a covered entity's review. - Denials carry a reason, because a bare "denied" is not usable by a support engineer or a customer. Dispositions follow the scoping analysis in kamo-internal **************** Permitted today: CRM, contacts, document vault, self-hosted email, e-sign, notes, tasks, calendar, internal chat, the in-cluster transcription/translation stack, and MLOS. Blocked with no obtainable BAA: social messaging, Canva, e-commerce sync, MCP gateway. Blocked pending named work: the AI router, RAG search, meeting recordings, VOIP recordings, marketing campaigns, analytics export. Schema note: HANDLES_PHI and PHI_EFFECTIVE_DATE materialize on the next KamoInitializer run, after CI publishes this shared-library build. Nothing reads the flag yet, so the ordering is safe.

  30. · kamo-signer-monorepo

    Locale-aware date formatting and per-locale metadata

Your universe is waiting.

Start free in minutes. Add products whenever you are ready — no migration, no new logins.

Free forever — no credit card required