மாற்று பதிவேடு
என்ன புதிது.
அது கப்பல்கள் போல், தளத்தில் ஒவ்வொரு மேம்பாடு.
· SecurityService
Serve the public mortgage application, addressed by product
Three unauthenticated endpoints under **************** plus the CRUD behind the organization's own state licences. **The organization is named by the PATH, never by the host.** A lender that has not pointed a domain at us still needs a white-labelled application to send borrowers to, and **************** is that link. Resolving the tenant from the hostname would paint every such lender in the platform's branding and file every lead under the platform organization — which is precisely what happens to anything proxied through the shared `api.kamocrm.com` gateway, where `X-Forwarded-Host` does not survive. `GET /{productId}` returns branding plus the disclosures a lender already publishes at the foot of its own pages — not the organization record; the endpoint ratchet was right to refuse that once already. `GET **************** is scoped to an ACTIVE team member OF THAT ORGANIZATION, so a guessed member id cannot publish somebody else's photograph and direct line. `POST /{productId}` is fail-CLOSED on Capcha (unlike sign-in, which stays permissive for a mobile client that sends none), rate-limited per IP in Redis, requires the consent box, and creates its lead through `LeadService.createLead` — which re-checks the product's owner before stamping it, and which every other intake path on the platform already funnels through. A product whose market is not RESIDENTIAL_MORTGAGE or COMMERCIAL_MORTGAGE answers 404, identically to a deleted one. All three are in the ratchet baseline with the reasoning written out. **`OrgStateLicenseController`** is how a lender enters what it is actually licensed to do, per state, gated on CONFIGURE_SYSTEM and scoped in the query on every write — a licence id is a UUID in a URL, and an update that resolves the row before checking the organization is an edit of another tenant's regulatory disclosure. Also: `applicationBaseUrl` on the vendors response, so the settings screen can offer a copyable link per product built on `verifiedRootDomain` — the same predicate the sign-in path routes on, decided here because a frontend deriving it from the domain list would hand an operator a hostname whose DNS has never been aimed at us. And `appsLogoOverlayColor` baked into the theme config.json, which is the only route the apps site's branding takes.
· kamo-shared-library
Record whether a borrower says this is their first home
A first-time buyer is eligible for programmes nobody else can use, so a lead that does not carry the answer costs the officer a call to ask it. The public application form asks; there was nowhere to put it. Nullable and three-valued deliberately. Null means "not asked", which is what every lead created before this column and every intake path that does not collect it should read as — defaulting to false would tell an officer the borrower answered no. Claimed, like everything else on LeadClaimMortgage: nobody has checked a title record, and the HMDA firewall on that entity applies unchanged.
· kamo-apps
Rebuild the mortgage application as a real, white-labelled form
The page this replaces was a prototype wearing a production URL. It posted to `http://localhost:8082`, so no application it ever collected reached anything. It named "Mark Johnson with American Lending Group, NMLS 343298954" as every visitor's loan officer. Its footer claimed a California Residential Mortgage Lending Act licence, an office at "123 Mortgage Lane", and a complaints line reading "1-800-XXX-XXXX" — under whichever tenant's logo happened to load. The consent text asked borrowers to agree to terms from "[Broker Name]". **Addressed by product, not by host.** `/loan/mortgage/{productId}`, with an optional `/{memberId}` for a loan officer's referral link. The product id names the organization, so the page works identically on a lender's own `apps.theirdomain.com` and on `apps.kamocrm.com` — same logo, colours, favicon, backgrounds and disclosures either way. That is what makes white-labelling possible for a lender who has not configured DNS, and it is why nothing here resolves a tenant from the hostname: a proxy to the shared API host loses `X-Forwarded-Host`, and every such lender would have resolved to the platform. A product whose market is not a mortgage market answers 404, identically to a deleted one, and the page says the application does not exist. **The footer is now the organization's own record** — legal name, NMLS with a Consumer Access link, real address and contact, the state licences it entered, its fair-housing mark, and its published policy links. A value the lender has not entered renders nothing at all. An empty footer is a gap somebody can fill; the previous one was a false regulatory statement nobody could detect. **Design.** Two columns: a fixed brand-coloured spine carrying the mark, the officer and a continuous progress rail, against paper. The spine is the whole idea — it answers "who am I giving this to" and "how much more is there" permanently, which are the two anxieties of this form. Fraunces for the statements, Inter Tight for the work, both self-hosted so neither the image build nor a borrower's first paint depends on Google. Tabular figures everywhere a number is typed. One orchestrated moment on load; after that, motion only answers an action, and `MotionConfig reducedMotion="user"` makes that a real preference rather than one framer ignores. **Submission** goes to SecurityService's public endpoint, fail-closed on an Capcha ***, and lands on a confirmation page that is a real route — it survives a refresh and a forwarded link. The receipt email is sent server-side from the same disclosure profile the footer uses, so the two cannot drift. Verified in a headless browser at 1440 and 390: five steps, validation, error states, the confirmation, the not-found page, and axe clean (WCAG 2.1 AA) on all four — one real contrast failure found and fixed in the rail.
· kamo-login
Seat every screen in a rounded pane under a corner ribbon
Every route now renders inside .kamo-pane: a card inset 15px from the browser edge with a 14px radius, a soft shadow, and overflow:hidden, so a page's own content is clipped to the rounded rectangle. The pane also carries a translateZ(0), which is what makes it the containing block for `position: fixed` descendants — without it the Powered-by pill and the Capcha backdrop anchor to the viewport and escape the clip entirely, which is exactly the case the clipping was asked for. The "Welcome Back!" ribbon is three layers around that pane, ordered within .kamo-shell: the darker tails behind it, the pane, then the red band in front. The band is rotated 45 degrees clockwise about the top-right corner diagonal and clipped by a box anchored 8px outside that corner, so it overhangs the top and right edges by exactly 8px, cut flush with the gap. The flush cut is not a shortcut, it is the only way to hit 8px. A band with square ends perpendicular to itself cannot overhang an edge by less than its own thickness over root two — 24px at a readable 34px band, far more than a 15px gap has room for. Cutting the ends parallel to the edges they cross decouples the overhang from the band's thickness, and gives both edges the same 8px. The tails start a band's width out from the corner so their near ends finish underneath it, run along each edge through the gap, and are then swallowed by the pane. Only --ribbon-over of each tail's thickness ever clears the pane, so the swallowtail notch is cut inside that strip; anything deeper never shows. Every dimension is a custom property on :root, and the whole assembly scales down under 640px. Every route sized itself to the viewport, and so overflowed the new pane by 30px. Each root now fills the pane instead — 100% in place of --app-dvh, 100dvh, 100vh and min-h-screen — and the safe-area padding moves up to the stage, which is where the inset is now applied. The stage falls back to 100dvh for the routes that do not measure visualViewport into --app-dvh themselves. Verified in a browser at 1440x900 and 390x844: the pane measures 15px from every edge, the clip box sits 8px outside the corner, both tails land where the geometry predicts, and the Powered-by pill is contained 16px inside the pane rather than the viewport.
· kamo-shared-library
Give a lender somewhere to put the truth about itself
The public mortgage application page composed its legal footer out of nothing — a hardcoded California licence, an invented NMLS number, a fictional address — and served it under every tenant's logo. There was no data source that could have made it true. This is that source, plus the two policy addresses the same footer needs and had nowhere to read. **Organization** gains `privacyPolicyUrl`, `termsOfServiceUrl` and `appsLogoOverlay`, all nullable and all in `patchOrganization`'s allow-list — which is not optional: a column that method does not name is accepted with a 200 and silently never stored, and the only symptom is a setting that reverts on its next load. **`OrgStateLicense`** is the company's own licence-by-state registry. Distinct from `mlos.legal.StewardLicense`, which is per-MEMBER and answers "may this officer work this file" — no officer's licence stands in for the company's. The entity, its repository (in `dao.repos`, which every service already scans; a new package fails context startup in every service that has not added it) and `LenderDisclosureProfile`, which reduces an organization plus its active licences to the sentences a footer prints. Its governing rule: a missing value produces NO LINE, never a plausible-looking default. An empty footer is a gap somebody can fill; a fabricated one is not detectable. **`WebAddresses.normalize`** turns `www.example.com/privacy.html` into an absolute URL, and refuses `javascript:`/`data:`. The value lands in an `href` on unauthenticated pages, where a scheme-less host resolves as a RELATIVE path — present, styled, clickable, and 404 for every visitor. **`LOAN_APPLICATION_RECEIVED`** is the borrower's receipt, white-labelled from the same disclosure profile as the page, so the two cannot drift — nobody reads their own confirmation email, so a licence removed from the website would otherwise keep going out by post for months. Its blocks are pre-rendered in `LoanApplicationReceivedEmail` because the renderer has no conditionals, and the send key is the DOTTED **************** not the CANONICAL_KEYS constant — the split that shipped QUOTE_SENT undeliverable. 18 new tests, including one asserting that an organization which entered no licence states no licence.
· kamo-internal
Publish the policy addresses, the real state licences, and a link per product
Three things the public loan-application site could not previously be told, and one it was told wrongly. **Company Info gains Privacy Policy Address and Terms of Service Address.** Both optional. Both normalized to an absolute URL on blur and again on the server, because the value lands in an `href` on pages anyone can open, and a bare host with no scheme resolves there as a RELATIVE path — the link stays present, styled and clickable, and 404s for every visitor. `app/lib/webAddress.ts` mirrors the shared library's `WebAddresses.normalize` so the box shows what will actually be stored rather than surprising the operator after a reload. **A State Licensing pane, under Company Info.** The mortgage application page shipped a hardcoded California disclosure — "Licensed by the Department of Business Oversight under the California Residential Mortgage Lending Act" — served under every tenant's logo, including tenants never licensed in California. That is a false regulatory statement made in a customer's name. There was no data source that could have made it true, so this is that source: per-state licence name, number, regulator, complaints line, and an optional exact wording that prints verbatim. An organization that enters nothing prints nothing; the pane shows the composed sentence before you save it, because the alternative to reading it here is hearing about it from a borrower. **Vendors & Products carries a copyable application link per product.** `https://apps.<verified **************** falling back to apps.kamocrm.com — and the alert above the table says plainly that the platform host is not a lesser link, because an operator who assumes it shows Kamo's branding will not send it. It does not: the page resolves the lender from the product id in the path. The base comes from the server (`verifiedRootDomain`, the same predicate sign-in routes on), never from the browser's hostname, so nobody is handed an address whose DNS has never been aimed at us. Only markets whose products actually have a page get the column; the others would 404. `npm test` green: 447 files, 5525 tests, and all fourteen guards.
· kamo-internal
Start a coder session from the Linux Desktop menu, at a model and effort you keep
The icon already opened a remote desktop and a remote terminal. The third thing people actually go to that machine for was reachable only by opening the Interaction Center, switching to Sessions and finding the New row -- three moves to do the thing the icon is for. before it is a destination, so the parent row launches nothing and its children are the answers. Model and Effort carry their current value in the label -- a member should not have to expand a row to learn what their session will run as -- and expand into radio groups read from AI_MODELS and AI_EFFORTS, so a model added to the vocabulary appears here without anybody remembering this file exists. The rule under Effort separates what the session will run as from the row that runs it, the same device the sizes list uses. The two settings are NOT local to this menu. They are the member's standing choice, read and written through useAiRunSettings -- the same hook the Send To AI model picked in any of the three is on screen in the other two before the menu has finished closing, and is still there on the next browser. It rides the member UI preference map, which is already read once per page load, so this costs no round trip. Three things had to change underneath: * NavToolItem gains `keepOpen`. Every row used to close the menu, which is right for a row that goes somewhere and wrong for one that adjusts a setting: model and effort are two halves of one decision with the launch under them, so closing on the first pick made choosing both a three-visit errand -- and collapsed the group, hiding the mark that had just moved. * opencoderSession joins openTerminal in app/lib/terminal. The Interaction Center starts sessions itself and has to, because it owns a starting row, an error line and a terminal list to refresh; every other surface has a menu row and nothing else. It never rejects -- a menu row has nowhere to put a rejected promise -- and hands the machine's own refusal to the toast, verbatim, the way the pane prints it. * The home launchpad card renders children. It drew only the top level of each section, which silently dropped every `children` array in toolSections: Remote Desktop, each Settings topic and now this one were rows a member could click forever with nothing happening. They were never broken rows -- they are questions whose answers were never on screen. It now expands them with the nav's own rule (a row with a destination goes there, a row that is only a parent expands, a row that is both goes there and toggles on its chevron), so the two surfaces reading one section list can no longer disagree about what a click means. The tooltip said "Remote Desktop or Terminal" and now names all three. Deleted from all 21 locale files in the dictionary in the same push, because editing English under a translated key changes nothing but English.
· kamo-internal
Finish the personal calendars panel, and put it where a member can reach it
PersonalProviderSection was scaffolding that nothing rendered, and it would not have worked if it had: it read GET /member/{id}, a route that did not exist, so it failed on open; it offered an Add button for Google and Microsoft that POSTed a row with no credentials, producing a connection that could never sync; its two-way toggle sent bidirectionalSync where the column is bidirectional, so the switch moved and nothing was stored; and it had a "Unify with organisation" switch for a field ContactIntegration does not have, which the service dropped on every write. Now it lists what a member has connected with its real sync state, connects Google and Microsoft through the OAuth flow, takes a URL and password for iCloud and custom servers, and says when a connection holds no credential -- which the switches alone cannot, and which is the difference between "off" and "cannot work". The unify switch is gone rather than faked: there is nowhere to store it, and a switch that silently persists nothing is worse than no switch. DavOAuthConnect gains a scope rather than being copied. Everything hard about it -- a callback on another origin, a popup that cannot report its own result, the re-read that is the only trustworthy signal -- is identical for a member and for an organization, and a second copy would drift on exactly the parts nobody re-reads. In member scope it appends ?member=true and the server takes the member from the session, so the panel cannot address anybody else. It lives in the calendar's own preferences drawer. The email settings topic is gated on MANAGE_EMAIL_SETTINGS, so putting personal calendars there would have hidden them from nearly everyone whose calendars they are. Last in the drawer and behind a divider: every other block there is a preference the panel owns, while this one authorizes a third party and can fail in ways the member has to act on. Key paths are literal and the namespace is inline. check-i18n-keys resolves useTranslations('<literal>') and gives up on useTranslations(SOMETHING) -- and giving up means skipping every key in the file, not just one, so a const there would have left all thirty unchecked. Reaching t through a ref to keep it out of the loader's dependencies hides a call site from the guard the same way, so the loader sets a flag and the render translates it.
· EmailService
Let a member connect their OWN calendars and contacts
The personal calendars panel asked for GET **************** which was never mapped -- so it failed on every open -- and for Google and Microsoft it had no way to authorize anything at all. This is the service half of making it work. The member travels in the OAuth state, and that is the only thing that separates the two flows. The app registration, the scopes and the registered redirect URI are all org-scoped and byte-identical for a personal connect and an organization-wide one, and the callback is sessionless -- so without a member id in `state` a person authorizing their own Google account would land the grant on the ORGANIZATION's row and repoint every colleague's sync at one person's calendar. Absent still means the organization, which is what every state written before this looks like. /oauth/url, /oauth/status and DELETE /oauth take ?member=true, and the member comes from the SESSION rather than the query -- a client naming a member id would be choosing whose calendar to connect. saveMemberOAuth upserts by (member, provider) rather than inserting like saveMemberIntegration does. Insert is right for a typed-in DAV server, since a person may keep two, and wrong for a grant: there is one Google account connected at a time, and re-consenting has to replace it rather than leave a second row for the sync jobs to both pick up. GET /member/{memberId} now exists and refuses any id but the caller's. Dropping the id and reading the session instead would have been simpler and wrong: asking for somebody else's calendars would then be answered with your own. There is no right that opens it -- a private Google grant is not an organization asset, so an administrator has no read on it either.
· kamo-internal
Gate the user account on platform rights, not on which org owns it
Authored in a concurrent session on this machine and committed here at the user's request; recorded so the provenance is not guessed at later. The user account is the PERSON, and editing it used to require the current organization to own the account. That was wrong twice over. An account's owner is a person, not an organization, so the condition answered a question about administrators and was being applied to somebody editing their own details. And it was a dead end in practice: the platform provides the account for almost every tenant member — 77 of 141 memberships on 2026-09-04 — and 37 of those people are not members of the organization that owns their account at all, so no workspace existed in which they could ever edit their own preferred name, website or links. The gate is now the owner, a god-eligible operator, the System User, or a holder of the platform rights that administer user accounts (MANAGE_USERS / SYSTEM_USER — the Users tab on /account). The top-level-organization precondition rides along rather than being checked separately, because a session inside a tenant resolves no platform right at all. Tenant rights stay deliberately absent. MANAGE_MEMBERS and MANAGE_MEMBER_SECURITY do not reach the user account: a tenant administrator can edit their own organization's security models, so a tenant holding either could grant itself the ability to rewrite the identity every other organization that person belongs to can see. That is the same reasoning that keeps them off the password. With the owning-org condition gone, the "your account is managed elsewhere" banner and the cross-org sign-in link it carried have nothing left to explain, so they and their plumbing are removed.
· EmailService
Give every provider's folder tree the same shape before any client sees it
Reporting a folder's SPECIAL-USE attribute was not enough, because a server only sends one if it is configured to — and the KamoMail Dovecot in production sends none at all: its listing answers role:null for Sent, Trash and Junk alike. So a Google mailbox still showed "[Gmail]" among the member's own folders with their real Sent buried inside it, and an empty "Sent" pinned above pointing at a folder Gmail does not have. FolderRoleMapper settles each role from, in order: the attribute the server sent, which is the only source that knows a localised "Gesendet" is the Sent folder; the paths this provider is known to use; and the folder's own leaf name, which is where every server without SPECIAL-USE ends up and is exactly what the client used to do for itself. A role is claimed once, so a member's own "Sent" on Gmail cannot take the slot from [Gmail]/Sent Mail. It also marks the containers a provider wraps its folders in — [Gmail] and [Google Mail] — as hidden. They are still listed, because their children name them as their parent, but no client shows them. Adding a provider is adding a row to the map rather than another branch in a component, and both the member's own mailbox and a shared one go through it.
· kamo-internal
Make Connect via OAuth on the CalDAV/CardDAV tab actually connect
The button raised alert('OAuth flow not yet implemented - configure credentials manually via the API'). It now opens the real Google Workspace / Microsoft 365 consent flow and reports what came back. The trust boundary is the interesting part. The popup completes on the callback registered in the provider's own console, which is served on the API host -- a different origin from this window -- so its postMessage usually fails the origin check and the outcome is learned from the popup simply closing. A signal that carries no result must never be read as a success, so every path ends by asking the server what it actually stored: a member who closes the consent screen is shown no connection. The panel also embeds OrgOAuthAppSection, so the app this connects through is visible and editable here, and it is the SAME registration the Provider Setup tab reads. Three fixes found while wiring it, all in this tab: - A render loop. The status loader depended on `t`; useTranslations() does not promise a stable identity between renders, so the loader was recreated, its effect refired, it set state, and round again. Nothing here holds `t` across a callback now -- a problem is carried either as wording the service chose or as one of four named keys. - The tab errored on every load. GET /org answers 204 for an org with no integration, and contact_integrations is empty cluster-wide, so res.json() threw on the empty body and every visit raised "Failed to load integration settings". - The two-way-sync switch never reflected what was stored: it read `bidirectionalSync` where the row carries `bidirectional`. Reads both, sends the one the column is named after. The sync bar beside it printed its service enum raw (IDLE / SYNCING / ERROR) with a hard-coded "Last sync:", and never showed the row's error message at all -- so an ERROR chip told a member something was wrong and gave them nothing to act on. It now names the states and shows the reason, and Sync now re-reads the row it just kicked off. The tab's remaining hard-coded English is translated too. Key paths are spelled out in full at every call site rather than built from a shared prefix const: scripts/check-i18n-keys.mjs resolves a literal t('a.b.c') and a template whose static part comes first, but cannot see through t(`${KEYS}.c`) -- verified by breaking a key behind such a prefix and watching the guard still report clean. Behind one of those, all 15 new keys would have shipped unguarded, and a typo renders as a raw key path on screen.
· EmailService
Connect Google and Microsoft calendars over OAuth, on the app the mailbox already uses
The CalDAV/CardDAV tab's Connect via OAuth button raised an alert saying the flow was not implemented and to configure credentials through the API. It now runs a real connect, and the constraint that shaped it is that an OAuth redirect URI lives in a THIRD PARTY's console: Google and Entra each hold exactly one address per app registration, typed in by whoever registered it, so a second flow cannot have a second callback. Both flows therefore land on the address already registered and are told apart by a `flow` marker inside `state` -- OAuthFlowKind, absent meaning EMAIL so callbacks in flight keep completing where they always did. No console change, no new gateway route, no new session exemption for a public path. What the new flow shares with the mailbox flow it shares deliberately: - The app registration. GroupwareOAuthService asks PlatformOAuthClientResolver the same question the Provider Setup tab asks -- the organization's OWN Google or Entra app if it registered one there, else Kamo's platform app -- so credentials entered once serve both, and an org that has neither is told so rather than sent to a consent screen that rejects a blank client_id. - The redirect URI, read from **************** rather than restated. One copy, because a second would be a redirect_uri_mismatch waiting for somebody to move a callback, and it is already covered by OAuthRedirectUriTest against the shared OAuthCallbackPaths table. What it does not share is the grant. It asks for Calendar and Contacts rather than Gmail and the Admin SDK, and stores its tokens on the org's ContactIntegration rather than its email provider row, so connecting calendars cannot disturb a working mailbox. Read-write scopes, not .readonly: two-way sync is a switch the member flips after consenting, and scopes are fixed at consent. The connect proves itself before reporting success -- it reads the account that granted access and lists one calendar. A grant that cannot read calendars, or that came back with no refresh token, is stored and flagged with what to do about it; both are otherwise invisible until a sync run days later. Credentials are written in the shape the four existing refreshers already read by string key (accessToken / refreshToken / tokenExpiry as epoch millis / clientId / clientSecret / tenantId), so a connection stays alive with no further work, and GroupwareOAuthServiceTest pins those names: renaming one compiles, deploys, connects, and stops working an hour later. Alongside, three fixes in the same surface: - GET /settings/integrations/org returned the entity, which put the org's AES-encrypted credentials blob in a response body, a browser cache and every proxy log between here and the member, on every load of a screen that never reads the field. Both org endpoints now return an explicit view of the row, reporting only WHETHER a credential is on file. - applyConfig read `bidirectional` while every settings screen has always sent `bidirectionalSync`, so the two-way-sync switch never persisted. Accepts both. - clearOrgCredentials, so Disconnect forgets the grant without resetting the sync options the member chose -- which deleteOrgIntegration would. The calendars-and-contacts sync itself is unchanged and still unbuilt: the Google and Microsoft GroupwareProviders are stubs delegating to the local DB, ProviderRegistry binds KamoGroupwareProvider whatever the provider type, and DaemonService's sync jobs only bump a syncToken. This lands the credential those will need, and nothing yet reads it.
· kamo-shared-library
Retry the member reads that fail first after a DDL
The member-by-id load is close to the first statement an authenticated request performs, so a catalog-version bump hits it before it hits anything else. That is why the last window was reported as "the scoreboard widget cannot be reached": the query that actually failed was a boring identity load shared by nearly every endpoint, and whichever feature announced its own failure loudest got the blame. Annotated here rather than at the 96 memberRepository.findById call sites in SecurityService, none of which is a chokepoint and most of which sit inside @Transactional methods where a retry would do nothing at all. These four reads are in the shared library, so one place covers the fleet. The retry is meaningful on exactly these methods because they are NOT @Transactional: each repository call runs in its own implicit transaction, so a second attempt genuinely gets a new one — the same reason it works in OrgResolutionService. Adding @Transactional to any of them later without moving the retry outwards with it would quietly turn these into no-ops. Reads only. Retrying a read is safe by inspection; the write paths on this class are left alone. The test asserts the annotation is IN FORCE on the real class through a real proxy, not that the retry helper works. It failed with the raw conflict until the context registered an auto-proxy creator, which is worth knowing: without one, the bean comes out unproxied and every annotation on it is inert while looking perfectly correct. Not covered: member.getOrganization(), which is a LAZY dereference resolved later under open-in-view, outside any method an annotation can wrap.
· MediaService
Send the sender's avatar with the message and its push
The chat notification carried `senderAvatar: ""` with the comment "avatar resolved on frontend", and the push carried no avatar at all. A browser can resolve one — it has the member directory loaded. A phone raising a floating head from a PUSH cannot: the app may not be running, so there is nothing to look anything up in, and the head showed initials until the member opened the conversation and minimized it again. memberAvatarUrl is the single-member twin of requestorAvatarUrls and re-reads the avatar row by id for the same reason: the association is lazy and Avatar is a JOINED hierarchy, so the proxy off getAvatar() reports the BASE type and every uploaded photo would be looked for at the vector path. Best-effort throughout — a missing face degrades to initials, which is what a 404 on the image would have produced anyway, and must not cost the message.
· kamo-shared-library
Retry a transactional method in a NEW transaction, and stop rights failing open
Two fixes that only make sense together. @RetryOnDbConflict retries a method OUTSIDE its transaction, so each attempt begins a fresh one. Retrying inside the transaction — which is what you get if any of this is subtly wrong — is not a smaller benefit but none at all: a transaction that has seen SQLState 40001 fails every subsequent statement, so each extra attempt re-runs work that cannot commit. It also looks like it is working, which is the dangerous part. The log fills with retries, the method really is called three times, and the error at the end is the one you would have had anyway. So the test counts TRANSACTIONS STARTED, not attempts. Those two numbers are identical in the working case and differ in the broken one, and nothing else separates them. It caught three real defects while this was being written: 1. A plain @Component advisor is invisible to the auto-proxy creator that @EnableTransactionManagement installs, which considers only advisors marked ROLE_INFRASTRUCTURE. Every retry silently did nothing. interceptor chain, so a second proceed() resumed past the transaction interceptor and re-invoked the target directly, inside the failed transaction. Each attempt now gets an invocableClone(). 3. Advice must not alter a method's contract, so a declared checked exception is carried across the retry boundary and rethrown unchanged. Built on plain Spring AOP, not @Aspect, to avoid putting aspectjweaver into sixty images for one interceptor. Second: MemberRightsAppliedService no longer continues when the security-model templates cannot be read. Logging a warning and carrying on sounds like the careful choice and is its opposite — templates are not only a source of extra rights, and a NUKE value is how a model says "off by default", so skipping them leaves a right standing that the organization intends to withhold. The result was then persisted. A transient database hiccup could therefore hand a member access nobody granted, with one WARN line as the only trace. It now throws with the cause attached, and the two entry points carry @RetryOnDbConflict — the realistic cause is a catalog-version bump, and a retry can only happen if the failure is allowed to reach it. 2757 tests green.
· kamo-marketing
Say what the Free plan does not charge, beside what the paid ones do
Now that Starter and Business each carry a one-time charge on the card, a Free card that says nothing about it invites exactly the wrong inference — the reader who has just read "$99 per seat" twice has no reason to assume the tier above the fold is different. The one plan where "no startup fee" is still true now says so, in the same position and the same voice as its neighbours, and answers the credit-card question in the same breath. Scoped to monthlyPrice === 0, which is Free alone. Enterprise is -1 and negotiates its own terms, so it asserts neither a fee nor the absence of one.
· SecurityService
Expose what a seat change will cost before it is made
Raising a subscription's seat count now raises an invoice — a per-seat startup fee follows the person, so ten hires on a $99 plan is $990 and a large account runs to four figures. The console needs something to ask before it commits an operator to that, and it has to be the server that answers: a negotiated agreement can override the fee, and a figure computed in the browser would be a second implementation of that rule with nothing keeping it honest. Read-only, gated on MANAGE_SUBSCRIPTIONS like the change it previews.
· EmailService
Send an organization's mail as its own domain when its SPF says we may
The From address was chosen by asking who hosts the organization's MAILBOXES. That is a different question from the one that matters, and it was wrong in both directions at once. chinilaw.com runs on Google Workspace and had added include:spf.kamocrm.com to its SPF — authorizing our relay explicitly, in writing, in DNS — and still got "Ron Chini, PC" <NoReply@kamocrm.com> on every verification and password-reset mail. Meanwhile six organizations whose SPF has never mentioned us were sending as their own domains, because we host their mailboxes: unauthenticated mail claiming a domain we do not send for, which is the configuration receivers spam-folder and reject outright under a strict DMARC policy. So the rule is now the DNS record: owned, and authorizing us. Both halves are required. Ownership without SPF is exactly the unauthenticated mail above; SPF without ownership would let anyone who added an include mechanism borrow a domain. Unknown reads as no — a domain the sweep has not reached yet sends from the platform address, because guessing wrong that way costs branding while guessing wrong the other way costs delivery, silently, on the message that most has to arrive. One row selection feeds both the address and the decision. Reading the flags off one domain while sending as another would authorize something nobody checked, and nothing would look wrong: the message still sends. A hard refusal at the relay now retries once from the platform address rather than losing the mail. That is deliberately NOT the protection against an unauthorized domain, and cannot be: an SPF failure is not a send failure — the relay accepts the message, the receiving server accepts it, and it is quietly spam-foldered or bounced later. The pre-check is the protection; this is for the narrower case where the send actually threw.
· kamo-shared-library
Let the console ask what a seat change costs before making it
changeSeats now raises an invoice for the startup fee on new heads, which turns a number in a dialog into a charge — ten hires on a $99 plan is $990, and on a large account it is four figures. An operator should agree to that rather than learn about it from the customer, so there is something to ask. Read-only, and resolved through exactly the code that will bill it. Computing the same figure in the console would be a second implementation of a rule that a negotiated agreement can override, and the number in the dialog has to be the number on the invoice.
· SecurityService
Record which domains authorize us, so their mail can go out as them
Transactional mail currently picks its From address by asking who hosts an organization's MAILBOXES. That is the wrong question. Whether we may send as chinilaw.com is decided by chinilaw.com's SPF record, and an organization on Google Workspace can authorize our relay perfectly well — chinilaw.com already has, and has been getting NoReply@kamocrm.com anyway. So the answer is read from DNS and written down, next to the ownership and SSL flags it sits beside conceptually. EmailService then reads a column instead of resolving DNS on the path that delivers a password reset. Its own sweep, not the SSL one. That sweep walks SSL-CONFIRMED domains, and SPF has nothing to do with certificates: chinilaw.com's ssl_confirmed is reopened over a missing careers certificate while its SPF is perfectly good. Hanging this off it would have skipped the one organization it was written for. A failed DNS lookup writes no verdict, only a timestamp. The two ways of being wrong here are not symmetric: recording an unauthorized domain as authorized sends unauthenticated mail that receivers spam-folder, and the org's password resets stop arriving with nothing to show for it; recording an authorized domain as unauthorized costs branding only. A resolver blip is not evidence about anybody's SPF record, and must not swap an organization's From address until the next sweep comes round. The advice endpoint writes the verdict too, so an owner who has just corrected their record sees it take effect on the page they are looking at, rather than a quarter of an hour later. Nothing reads the flag yet; the EmailService change follows.
· kamo-shared-library
Record whether a domain's SPF authorizes us to send as it
Two columns beside the ownership and SSL flags, answering the third DNS question about a domain: may we put its name on the From line of the transactional mail we send for it. The columns exist in the database already — landed before this, deliberately. Mapping a column that the table does not have breaks every query against org_domains across the whole fleet, and the 500 turns up in whichever repository next reads a domain rather than in this one. Both nullable and without a DEFAULT. NULL means "never checked", which is not the same statement as "checked, and not authorized": the first is silence, the second is evidence. Keeping them apart is what lets a failed DNS lookup leave an existing verdict standing instead of quietly demoting an organization's mail to the platform address until the next sweep.
· kamo-internal
A put-away terminal's hexhead says whether it wants you
A member with six terminals minimized has one question about each: is that one waiting for me? The head now answers it in a colour. green working — nothing is wanted from you amber waiting for you, and you have not looked since it started waiting grey waiting, and you have seen it Three states and no more. A fourth would have to be read rather than glanced at, and a glance is the whole point of a colour. Green and amber are the emulator's own — the same green the terminal list already uses for "this shell is attached" — so somebody who has learned one has learned the other. Grey is deliberately not a colour: a terminal you have already seen should recede, and the way to recede is to stop being coloured. The busy/waiting half comes from the machine (a shell's foreground command, a machine can say a terminal is waiting, but only this browser knows whether the person has since looked at it — and without that distinction six idle terminals glow for attention for ever and the colour stops meaning anything. Reviewed means seen SINCE IT STARTED WAITING, not seen ever. A terminal you opened, read and put away, which then went off and did more work and stopped again, is asking for you a second time; a rule that only remembered "you have looked at this one" could never say so. So the moment a terminal stops being busy is recorded and compared against. The TRANSITION is what is recorded, never the state — a terminal already waiting when the page loaded had none, and stamping "now" would mark every terminal on the machine unreviewed after every refresh. Keyed on the shell's session id, not the tool window's: window ids come from a counter that resets on every page load, so a review recorded against one would follow whichever terminal happened to open first after a refresh. Polls at 3s rather than the panel's 10s — a list is something you go and read, but a colour is something you notice, and one that takes ten seconds to go amber after a build finishes is a colour you learn not to trust. Only while something is actually minimized, so a member with no heads out pays nothing. The colour is pushed only when it has CHANGED, so a head that has been amber for ten minutes does not re-render every three seconds. HexHeadTerminalStateSync sits beside HexHeadUnreadSync and exists for the same reason: it is the only thing that can see both the window list and a data source the hexhead engine knows nothing about. 13 guards and 5381 tests. check-i18n-keys stays red on another session's subscription work, nothing here.
· SecurityService
Carry a terminal's activity state to the console
TerminalSummary gains `state`: busy, or waiting for its member. Read off the session's transcript — neither of which is knowable from here, which is why it travels rather than being derived. Null from an agent too old to send it, which the console reads as waiting: a wrong "busy" hides that a terminal needs somebody, a wrong "waiting" only draws the eye.
· kamo-internal
Give a head the app's own tooltip, placed from where it sits
The label was the browser's native `title` box: unstyled, and put wherever the other 291 hover targets in this chrome use, arrow and all. A head is not a normal hover target, so the tooltip is driven rather than triggered. MUI's own listeners are all off: the element it would bind them to is a zero-sized transform wrapper nothing can hover, and its touch long-press is the same gesture that opens the head's context menu. The stage owns one shared popper, opens it off HexHead's pointer enter/leave, and closes it on any press — a label hanging off a head being flung across the screen is not a label. A 400ms trail of flashing labels. The anchor is a virtual element reading the head's motion values on every call, not the DOM node. Heads move by writing their transform straight to the DOM with no React render in between, so a rect measured at open goes stale the moment a cluster reflows around a newly opened window; Popper is nudged to re-run whenever those values change. Placement is decided from where the head is, by placeTooltip: open away from the nearer horizontal edge, and for a head in the outer third of the screen align the tooltip's near edge to it so it grows inward instead of being shoved back on screen. Popper's flip and preventOverflow stay underneath as the safety net, on the viewport with the same 8px keep-out band the context menu uses — they cover what geometry alone cannot, a title wider than the space the placement leaves. Verified in Chrome, not only in jsdom: eight head positions (four corners, the centre, a 90-character title in the corner, and two heads dragged off the edge) across a 1200x800 and a 480x900 viewport, all sixteen labels fully inside the margin; and a head moved 250x120px by motion value alone, with the label tracking it to the pixel.
· kamo-marketing
State the startup fee, and stop claiming there isn't one
The site said "$0 setup fees" three times in the pricing page's metadata and listed "No mandatory onboarding fees" as an advantage over HubSpot. All of it is now false: paid plans carry a one-time $99 per seat. A pricing page that omits a four-figure charge on a 25-seat order is not merely incomplete, it is the reason someone meets the number for the first time at checkout. The HubSpot comparison survives honestly. Their $1,500-$3,000 Professional onboarding recurs per Hub; ours is once per seat and an order of magnitude smaller. That was always the real point — the old bullet just made it by claiming a zero we no longer charge. The fee is read off the catalog rather than written into the copy, for the same reason the seat ceiling is: it is a number the server bills on, and a sentence stating it would keep stating the old one the day it changes. The arithmetic lives in plan-pricing.ts beside the term ladder, mirroring SetupFees on the server, and the guard that exists to catch a wrong number rendering perfectly now checks it. One deliberate asymmetry at the catalog boundary: an absent model defaults to PER_SEAT here, where the server defaults to FLAT. This site renders exactly one catalog and every paid plan in it is per-seat; the API reports the amount with no model until the services are rebuilt against the shared library that introduced it. Defaulting to FLAT in that window would print "$99" against an order really billed $2,475, and understating a charge in public is the failure worth defaulting away from. An explicit FLAT is always believed. The calculator carries the fee on its own line rather than in the total, which is a recurring monthly figure that perUser divides — summing them would budget a one-time charge twelve times over.
· kamo-internal
Show the startup fee before it is charged, and where a deal overrides it
calculate-price has always returned a setupFee and the order wizard printed it nowhere, so a charge that runs to four figures on a large order reached the customer's first invoice without ever appearing on the quote they agreed to. It sits beside the subtotal now, on the step where the seat count is chosen. The payment chip read "AUTO_BILL ? Auto-Bill : Invoice", which labelled a comped account and a cleared wire as an outstanding bill — the two states that most need telling apart from one. It is a table now, and both WIRE and COMP are selectable where the method is changed, which they were not. The plan editors gain the model beside the amount, because $99 in setupFee is either $99 or $2,475 on a 25-seat order and the number alone cannot say which. Negotiated terms get a panel in the platform console's org drawer, beside the commercial figures rather than buried, since they are the reason those figures disagree with the price list. A lapsed agreement stays listed but dimmed: it must not read as the one in force, and it must not vanish either, because it is what explains an invoice already raised.
· InitializerService
Seed the $99 per-seat startup fee on every paid tier
The loader passed null for setupFee on all four plans, so a freshly seeded environment priced onboarding at nothing while production charges $99 a seat — the kind of gap that is only found by someone testing a quote and believing it. The model is passed at every call site alongside the amount, because the amount alone does not say what it means, and the rate is named once so the three paid tiers cannot drift apart. Free keeps no fee at all.
· BillingService
Bill the startup fee on both routes to a plan, and record negotiated terms
Two routes reach the same plan and only one of them was going to charge the new per-seat startup fee. A sales-written order bills through SubscriptionService, which never touches Stripe; self-serve checkout goes straight to a Checkout Session and would have charged nobody who signed up themselves. The same plan would have cost different money depending on how it was bought, so the fee gets a non-recurring Stripe price and a line item, skipped when an agreement waives it and skipped rather than fatal when no price exists yet — a missing price must not stop a customer subscribing, and a fee is recoverable on an invoice in a way an abandoned checkout is not. The agreements API is platform-admin only and deliberately so: an agreement moves money in the customer's favour against the published list, which is Kamo's decision and not the customer owner's. Nothing deletes — withdrawing deactivates and keeps the row, because an invoice raised while it was live has to stay explicable. Two ways of recording one wrong are refused outright: a FLAT agreement with no amount prices at zero and reads as a waiver nobody agreed to, and an agreement edited through the wrong organization's page re-prices someone who was never part of the conversation. The DDL is by hand, as the COMP scripts beside it were: InitializerService is not deployed, Hibernate does not re-issue a CHECK on an existing table, and setup_fee_model is deliberately nullable so no existing plan's onboarding charge is multiplied by a default nobody chose.
· kamo-shared-library
Charge the startup fee, per seat, and let one customer out of it
The startup fee has been half-built for as long as the model has existed. SubscriptionPlan carried a setupFee, SubscriptionOrder carried a setupFee, OrderLineType and InvoiceLineType both had SETUP_FEE — and none of it ever reached a bill. calculatePrice added the plan's amount to the total exactly once, and generateInvoice emitted PLAN and ADDON lines and stopped, so a fee that was quoted, agreed and stored was charged to nobody. KamoCRM now charges $99 per seat. A single amount cannot say that: $99 in setup_fee is either $99 or $2,475 on a 25-seat order, and nothing on the row distinguished them. So the meaning is stated beside the amount, with FLAT as the default — every plan the platform hosts for a third party was authored as a single charge and must not be retroactively multiplied. Three callers need the answer and one of them is new. A per-seat fee follows the person, so growing the team owes it again on the difference; without that a 5-seat account could reach 500 having paid setup on five. changeSeats now raises its own one-line invoice for the new heads, rather than burying a one-time charge inside a renewal that may be eleven months away. Harmony Home Loans agreed a flat $2,000 covering every seat they will ever add, before signing. Sales had a promotion (a code, open to anyone who types it, expressed as a percentage) and hand-edited order totals, and neither holds that: a promotion has no per-account scope, and an edited order is a fact about one order, forgotten the next time the account is priced. OrgBillingAgreement records it against the organization instead, where pricing reads it every time. "$2,000 instead of $99 a seat" and "$2,000 and never again" are different promises, so coversFutureSeats is asked on its own rather than inferred — a customer who bought the first would be billed correctly today and wrongly the month they hire. WIRE joins the payment methods because Harmony's money had already cleared and there was nowhere to say so. It is not INVOICE (nothing is outstanding, dunning must not chase it) and emphatically not COMP, which is what they were carried as for a fortnight while the wire sat in the bank — an account reading as complimentary in every report that counts revenue.

உங்கள் உலகம் காத்திருக்கிறது.
நிமிடங்களில் இலவசமாகத் தொடங்கவும். நீங்கள் தயாராக இருக்கும் போது பொருட்களை சேர்க்கவும் - எந்த மாற்றமும் இல்லை, புதிய உள்நுழைவுகள் இல்லை.
எப்பொழுதும் இலவசம் — கடன் அட்டை தேவையில்லை