Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-internal
A "why can't I accept?" override on the filter bar
Right-aligned on the /leads filter bar, wearing the Leads nav icon with the same prohibited emblem — so the thing on the nav that says "you are blocked" and the thing here that says "here is why" are visibly one statement. Only rendered while the member is actually blocked. It is not a filter that happens to be empty, it is a state that either exists or does not, so when the prohibition clears — a realtime accept-gate event, or a refresh after the last note was written — the control disappears and the filters come back on their own. An effect drops the override when that happens: leaving it latched would strand the grid on a `uid IN ()` matching nothing, with the filters still greyed and no visible control left to turn them back on. Pressed, it OVERRIDES rather than narrows. buildLeadGridQuery returns early on blockingLeadUids, so no market tab, status tab, agent, date or search term applies. That is deliberate: the blocking leads are scattered across whatever markets and statuses they happen to be in, and intersecting with the bar would show a subset while reading as the whole list — exactly the wrong thing to tell somebody trying to get unblocked. An empty set asks for `uid=0` rather than dropping the constraint, so it can never widen to the organisation. The status tabs and every control on the bar are greyed and made inert while it is on, because a bar that still looked live would be lying about what chose the rows. The toggle itself is excluded by attribute so it stays pressable, and the action buttons beside the tabs are not filters so they stay live. Paging resets on each toggle — the result set is a different one and the old page number means nothing against it. Off, the button pulses: it is the one thing on the bar a blocked member has to notice, since it is why their leads have stopped arriving. On, the grid itself is the signal and the animation would be noise. Honours prefers-reduced-motion.
· SecurityService
List, rename and end a member's terminals
GET **************** DELETE and PATCH on /terminals/{id}. Every one of them resolves the Linux account from the CALLER'S OWN SESSION and passes no account name to the agent that came from the request. A member can therefore only ever see, rename or end their own terminals — there is no id they could supply that would reach somebody else's, because the account is not part of what they supply. An unreachable machine answers 200 with an empty list and `unreachable: true` rather than an error: "the dev machine is down" is a fact about the machine, not a failure of the question, and the console can say so where an error page could only say nothing. That is the same rule DesktopSsoController follows. Renaming a session that has ended is a 404 with a sentence — the honest status for "you renamed something that is not there any more", which after a reboot is every terminal the member had.
· KlusterServices
List, rename and end a member's terminals
GET /terminals?user=X, POST /terminals/<id>/kill, POST /terminals/<id>/title. THERE IS NO DATABASE BEHIND THIS, and that is the design rather than a shortcut. The tmux server on the machine IS the registry: a member's terminals are exactly the sessions that exist, so one that ends stops being listed with nothing to reconcile, and a reboot — which takes the tmux server with it — empties the list by itself. A table would have to be told all of that, and would be wrong every time it was not. Custom titles live in a tmux user option on the session, so a title has the same lifetime as the shell it names. Control characters are stripped before storage: a title is rendered in a list, and an escape sequence in one would repaint the screen around it — stripping is also what keeps the field separator out of the value, which is what makes the listing parseable by splitting on it. Titles are passed as a single argv element, never through a shell. "No tmux server" is an empty list, not an error. That is the state of the machine after a reboot, which is precisely when the member needs the list to say "nothing here" rather than to fail. 20 tests: the listing shape and its ordering, untitled sessions reporting null rather than "", sessions this agent did not create staying invisible, malformed and non-numeric rows being skipped rather than fatal, kill being idempotent, titles being stripped/truncated/unset, and a title containing shell metacharacters arriving as one argv element. Also guards pump's select against a descriptor closed under it — the same ordinary hang-up ending the recv path already handled. 131 tests.
· kamo-internal
A password shortcut in the hero, and a narrower gate
Two halves of the same subject. The shortcut. Changing your password was three rooms deep behind the rail — the one thing the room layout made genuinely harder to reach than the old scroll, and it is the account's most consequential control. The hero now carries it, deliberately NOT as a third glass pill: the two beside it are destinations, this is an action, so it is solid. When multi-factor is off it turns amber and says so, because that is the moment the prompt is worth making. It routes through the same focus path the command palette uses, so it opens the Access room AND scrolls the password card into view. That path is now one function shared by the palette, the strength ring and this shortcut, rather than three that could drift; the ring's segments already named the field that fixes them and now actually use it. PasswordSecurityCard is not a ProfileCard, so its wrapper carries the scroll anchor. The gate. MANAGE_MEMBER_SECURITY no longer shows the password card on somebody else's record — only the member themselves or a god-eligible viewer. This is the DISPLAY half; the half that matters shipped in SecurityService 197b591, which returns 403 for the same condition. A right removed from a screen is a tidier screen, not a security control. Dictionary keys landed first in kamo-translation-dictionary 982c1f62. Verified: npm test (14 guards + 5,139 tests), tsc --noEmit, next build.
· kamo-internal
Prohibited nav emblem, Assign to Myself, and obfuscated contact masking
**The Leads nav icon says when you cannot accept.** A member who holds leads they have not worked gets a red block emblem over the Leads icon and a tooltip naming what is blocked, why, and what to clear. Fed by /leads/available-summary and kept live by a new accept-gate event on the credit/assignment socket — the payload carries only a member id, so each browser ignores everybody else's and re-asks the server for its own. Shown to any member who can see leads, not only credit holders: since a free-for-all lead can now be self-claimed, the gate governs them too. The availability dot stays credit-only, so the two statements remain separable — you can be blocked from accepting while leads sit there available, and those are different things to say. **Every status is named the way THIS org names it.** The tooltip, the /leads/available notice and the detail-page copy resolve New Lead / Farm Fresh through useStatusLabels: labels are replaceable on /settings/features/crm, and telling a member to move a lead off "New Lead" when their org calls it something else names a status they cannot find in the picker. **/leads/available's notice splits into its two reasons** — leads owed a note, leads owed a status change — because merged into one list they read as an undifferentiated pile and the member has to open each lead to learn what it wants. A lead failing both is listed once, badged. **Assign to Myself** sits under Hot Reassign on an unassigned lead. Offered on a free-for-all lead to anyone not currently barred, and on any unassigned lead to a holder of ASSIGN_LEADS. The response carries the lead back unmasked, so setting it into state flips assignedToMe, recomputes the mask decision to UNMASKED, and reveals the contact information with no reload. **The status control pulses** while the lead is mine and still at New Lead or Farm Fresh — that is the control that fixes it, and the header is read long before a nav tooltip. Honours prefers-reduced-motion. **Masked contact info is obfuscated, not blanked.** contactObfuscator.ts mirrors the server's ContactObfuscator exactly, asserting the same strings, because the realtime lead relay ships a full unmasked DTO to every subscriber in the org and the browser is the only thing standing in front of it there. A blank field reads as "this lead has no e-mail", which is a different and wrong statement. **The /leads Unassigned filter is offered to everyone.** Without VIEW_UNASSIGNED_LEADS it returns the unassigned free-for-all leads this member may claim, and the label says so rather than the option quietly meaning two different things. The client no longer forces assigned=ASSIGNED for those members — the server's floor is wider than that now, and sending ASSIGNED would clip exactly the rows this change exists to show.
· SecurityService
Serve the widened accept gate, add self-assignment, and close two contact-mask bypasses
**Endpoints for the new gate.** /leads/available-summary and /leads/available now report both reasons a member is blocked — leads owed a note since they were assigned, and leads still at their intake status — with the leads themselves and the SYSTEM status wording plus ids, so the browser can resolve the org's own custom labels before showing anybody the reason they are stuck. blockedByComments is kept as an alias of the new `prohibited` so a browser still running the previous bundle keeps working through the deploy. The gate is evaluated for anyone who can see leads, not only members holding SPEND_RECEIVE_LEAD_CREDIT: a member who can only claim a free-for-all lead is subject to the same discipline and their nav has to be able to say so. The credit-pool scan below it still requires the credit right. **POST /leads/{id}/assign-to-me** claims one unassigned lead. It returns the lead UNMASKED — the caller is its assignee by the time the server answers — which is what lets the detail page reveal the contact information without a reload. **A new NATS subject, **************** published when a note lands on a lead, when a status changes, and on both sides of every assignment change. MediaService's relay already subscribes to security.leads.> and maps subjects to topics generically, so it needs no change. The payload carries only a member id: the gate's answer depends on that member's whole book, so there is nothing useful to put on an org-wide topic, and a subject every member receives is the wrong place for anything worth withholding. **Two contact-mask bypasses, both found auditing where a lead's details can reach a browser:** - GET /leads/{id}/history had NO assignment or contact gate at all. A field-change log is a complete record of every value a field has ever held, so it returned the e-mail and phone numbers the lead's own mask takes out — and not just the current ones, every previous one too. It now carries the same two decisions as getLeadById, and LeadHistoryContactMask obfuscates the contact-bearing rows. Status, assignment and loan-amount history are untouched: this withholds contact information, not the audit trail. - GET /leads/callback-context/{id} shipped an e-mail address and two phone numbers for any callback in the org, ungated. A callback id was a way to read the contact details of any colleague's lead. **A write-back guard on updateLead.** The read mask no longer blanks contact fields, it obfuscates them — so a masked caller now holds real-looking strings that would persist perfectly happily. The right hierarchy makes this hard to reach, but "hard to reach" is not a guarantee and the failure mode is silent corruption of a customer's phone number. **Grid visibility.** Without VIEW_UNASSIGNED_LEADS the /leads floor becomes "my leads plus unassigned free-for-all leads" rather than "my leads", and an unassigned free-for-all lead can be opened and its history read — a row you can see but cannot open is a bug. Reading the CORRESPONDENCE on one is a different question and LeadCommsReadGate still answers it with VIEW_LEAD_CONTACT_INFO_OTHERS; the grid-comms scope deliberately keeps the narrower floor, with a comment saying why the two now differ.
· kamo-shared-library
Widen the accept gate, obfuscate masked contact info, let free-for-all leads be claimed
Three related changes to the lead-assignment core. **The accept gate now asks two questions, over every assigned lead.** LeadAcceptEligibilityService replaces the comment gate that lived inside CreditLeadAssignmentService. A lead blocks its holder from taking another when either it has no note from them written SINCE it was assigned to them, or it is still sitting at Fresh > New Lead / Farm > Farm Fresh. Both halves close a hole. The old rule counted any note ever written, so a lead that came back to a member passed on a note from their previous stint with it. And it looked only at leads acquired through the auto-assignment system, which left every other route — a manager's hand-assignment, an import, a self-claim — as a way to accumulate untouched leads with the pool still open. The rule is two set-based queries whatever the size of the member's book: the current assignments, then one grouped MediaObj aggregate over their stream guids. Widening the scope to every assignment made the old query-per-lead loop unaffordable — it is the shape that made the /leads grid slow, and this runs on every render of the leads nav for every signed-in member. The blocking statuses are named explicitly in LeadStatusType rather than derived from isAllowSwitchBack. Those two happen to be the only statuses carrying that flag, but it answers a different question, and a future status marked the same way must not silently start blocking accepts. Matching is on the stored system descriptions, so an org's custom status labels never affect the rule. **Masked contact info is obfuscated rather than blanked.** ContactObfuscator turns (555) 555-5555 into (555) 555-**55, snadjafinia@gmail.com into s**********@gmail.com, and 123 Main St into *** Main St. A blank field is indistinguishable from one nobody filled in, so a restricted viewer could not tell "there is a number here you may not read" from "this lead has no number" — and staff chased the second reading. City, postal code and country now survive on the same basis the state always did: coarse enough to identify a duplicate, useless for reaching anybody once the street number is gone. Date of birth stays absent; no partial form of it is both recognisable and safe. The browser mirrors this exactly **************** because LeadRealtimePublisher fans a full unmasked DTO out to every subscriber in the org with no per-recipient projection possible. The Java and vitest suites assert the same strings so the two cannot drift apart unnoticed. **Unassigned free-for-all leads are visible and claimable.** **************** claims one specific unassigned lead. Free-for-all is the open door and carries the accept gate with it — otherwise it would be the way around the discipline it exists alongside — and mints/spends its own credit exactly as acceptNextLead does, so Max Daily Spend still caps it. ASSIGN_LEADS is the elevated door: any unassigned lead, no gate, no credit. Licensing is a legal constraint and neither door waives it. LeadGridQuery gains **************** turning the grid's permission floor from "leads assigned to me" into "leads assigned to me, plus anything genuinely up for grabs". Hiding unclaimed free-for-all leads from everyone without VIEW_UNASSIGNED_LEADS left them claimable in theory and unreachable in practice.
· kamo-internal
Flags on languages, and stop copying platform lists
Completes c20bac57. Languages now carry their flag, the same one the NavTop switcher shows and from the same source — the closed value as well as every row in the menu. Getting there exposed the real problem: options.ts had grown private copies of four lists the platform already owns, which is how the page it replaced worked too. • Languages were a retyped copy with no country codes, so a flag was not even possible. Now app/lib/languages.ts, which the NavTop switcher and the chat translator already share. • Currencies were a 34-entry seed against the platform's 160-odd, so a member paid in naira or shekels read their own currency as a bare code with no symbol and no name. Now app/constants/currencies.ts. • Time zones were retyped from the same 27 names. Now derived from TIMEZONE_ENUM_TO_IANA, the one complete map, so the picker cannot offer a zone nothing can resolve. • Work location keeps its values but they are now checked against the Java enum by test. Team membership status stays an explicit list on purpose: the generated TypeScript mirror is STALE and missing CONTRACTS_SENT, so deriving it from there would silently drop a status members are really set to. The Java enum has nine; a test pins all nine. Time zone rows now show what time it is there. Twenty-seven enum names with the underscores removed is a list you have to already know the answer to read; the time is the thing being chosen, so it is the thing shown. Also fixes a claim the first commit did not deliver: framer-motion does not consult prefers-reduced-motion by itself, so the room crossfade and the save bar animated regardless of the setting. MotionConfig reducedMotion="user" now covers both; the CSS-driven pieces already carried their own queries. And the country field renders its flag in the read view, matching the picker that produced it. Verified: npm test (14 guards + 5,121 tests, 70 of them over app/lib/memberProfile), tsc --noEmit, next build.
· kamo-internal
Rebuild My Profile as a hero and five rooms
**************** was one 1,999-line component rendering thirteen identical cards down a single column. One SectionCard helper gave all of them the same radius, the same shadow, the same icon chip and the same Edit / Save / Cancel trio, so "Account Information" — two dates nobody can edit — carried exactly the weight of "Password & MFA". The layout was telling the truth about a hierarchy the page did not have. The rebuild: • A hero that keeps MemberHeader's wallpaper resolution verbatim, scar comments included, and adds the identity it was only decorating: the name as the page's one piece of large type, the member's local time ticking in THEIR zone, MFA state, last sign-in, and a strength ring. • Five rooms behind a rail — Identity, Appearance, Work, Access, Regional — one open at a time, carried in the URL as &room=. A room whose every card is gated away for the viewer renders no rail item at all. • Click-to-edit fields and one room-level save bar, replacing 39 buttons. Saves fan out by endpoint under allSettled: a group that succeeds commits while a group that fails keeps its fields dirty with a reason. • Cmd+K over every field, so rooms hide things without hiding them. • Card-shaped staggered skeletons instead of one full-tab spinner. Two defects found while reading, fixed here: • The address never worked in EITHER direction. The loader hardcoded five empty strings and never read data.address, which SecurityService does send; and the client's {street, city, state, zip, country} is not the wire shape — Member.address is an EmbAddress of {address, addressDetails, locality, subdivision, postalCode, country}. So it rendered blank for everyone and saved keys the entity does not carry. Now uses the shape the rest of the platform already sends, with round-trip tests. • "Legal name:" and "Born " were English literals in MemberHeader, and the work-location and employment-status labels were English literals in the component. All keyed. Structure. The rules moved to app/lib/memberProfile, where vitest actually collects them — app/settings/** is not in the include allowlist, so a test written beside the page would have passed by never running. fields.ts is the keystone: every field declared once with its room, card, label, gate and endpoint. The palette, the strength ring, the save fan-out and rights gating all read it instead of reimplementing it, and 57 tests pin the gates down — including that no field is ever more visible than its card, and that editing is never wider than seeing. Quick Tips is dropped for this tab only, by omitting helpTips; SettingsView renders the rail from the active tab, so the other nine keep theirs. Impersonate moves from the settings header into the hero. Also deletes ProfileTab.tsx (2,650 lines), LoginSecurityTab.tsx (787) and MembershipPermissionsTab.tsx (2,136), which had no importers. Dictionary keys landed first in kamo-translation-dictionary 4bb91245. Verified: npm test (14 guards + 5,106 tests), tsc --noEmit, next build, and check-i18n-keys --require-messages against a fresh clone of the dictionary.
· kamo-internal
A real Linux terminal in a tool window, and a submenu to reach it
The Linux Desktop nav option becomes a menu instead of a launch — Remote Desktop with its sizes, Remote Terminal — exactly as the softphone owns call, voicemail and text. One registry change does it: `kind: 'action'` becomes `kind: 'tool'`, and NavToolButton already renders a submenu in all three placements while toolSections is already the shared source for the nav and the home launchpad, so the two cannot drift. THE TERMINAL. xterm.js against a real PTY on the member's own dev machine, so it is a terminal and not a command runner: alt-screen apps (vim, htop, less), 24-bit colour, mouse reporting, bracketed paste, wide and combining glyphs, clickable OSC 8 links. Nothing is interpreted on the way through — keystrokes are bytes to the PTY and output is bytes back — which is the only way a terminal is ever actually compatible with what people run in one. On top of the emulator: a status chip that pulses rather than relying on colour alone, a title that follows the shell's own OSC sequence so it tracks cd and the running command, copy-on-select with Ctrl+Shift+C/V, Ctrl+Shift+F search, Ctrl +/-/0 zoom, and a palette that follows the viewer's theme. The shell outlives the window. It runs under tmux keyed on the window id, so a refresh, a dropped connection or a tool-window restore reattaches to the same session with its scrollback and its running command — which is also why a dropped socket reconnects with backoff instead of reporting a failure. The transport lives in **************** free of React and of xterm, so backoff and the frame split are testable without a DOM. THE STORED DESKTOP SIZE IS GONE, and with it DesktopResolutionPicker. It was set on a settings page four screens from every launch and read out of localStorage, so "why did it open small?" had no answer anywhere near the thing that opened small. The size is now chosen in the same click that opens the desktop; openKamoDesktop takes it explicitly and a caller with no opinion passes `fit`, which is what the default always was. Three defects found and fixed in self-review, all silent: the shortcut handler was attached by an effect that fires before the async emulator exists, so it never bound at all; closing a window while its ticket request was in flight still opened a socket afterwards, leaving a shell attached to nothing that the per-member cap went on counting; and the search decorations omitted ISearchOptions' two REQUIRED overview-ruler colours. Typing the xterm refs instead of `any` is what caught the last one. 405 test files / 5049 tests pass, the 14 guards are clean, tsc is clean, and the real production build succeeds — a green tsc alone proves nothing here. Dictionary pushed first (115c267f); verified against a fresh clone the way CI does it, since local messages/ is gitignored.
· kamo-internal
A fourth window state — float, drag to detach, drag to reorder
A popup tool window has had three states: docked in the row along the bottom, maximized over the viewport, or minimized to a chat head. This adds FLOATING — detached from the row, dropped anywhere on the screen, and resized from any edge — and reaches it two ways. THE BUTTON. A control in the title bar, wearing the same icon that detaches a note, lifts the window off the slot it was in and cascades it clear of the dock so the eye can follow it. Pressed again it goes back to the row. A maximized window is un-maximized and centred instead, since it has no slot to come off. THE DRAG, which is the interesting half. Pressing the title bar and pulling is ONE gesture with three outcomes, and where the window is let go decides which: - released with its bottom edge within 96px of the viewport's bottom, it takes a slot in the row — so dragging a docked window sideways REORDERS the dock, which was not previously possible at all; - released anywhere else it floats there, which for a docked window is the moment it detaches. It detaches from exactly where it was, so nothing jumps; - and a floating window dragged back down snaps into the row as it approaches, at its standard size and in order with its neighbours. The bottom EDGE is measured, not the pointer: dragging a 650px window by its title bar puts the pointer 600px above the dock on the first frame, and a pointer test would detach every attempt at a reorder. Nothing is committed until the pointer comes up. The carried window, the neighbours shuffling to make room (or closing ranks behind it), the drop rail brightening as the dock nears and the halo around the slot it will land in are all a preview resolved from the live box each frame — which is what makes Escape, and changing your mind halfway, cost nothing. Two smaller decisions worth naming. Docked windows are now positioned from their TOP edge rather than pinned with "bottom: 0"; top and bottom are different properties and no transition carries an element between them, so the snap would have jumped however it was timed. And "resizable: false" becomes a FLOOR once a window is off the row rather than a veto — the email composer is one of the fixed-size tools, and it is the window members most want more room for. The row order and the float geometry both survive a reload: the snapshot carries the float box, the dock size to return to, and the array order, which is the member's own left-to-right arrangement. Requires @kamo/tool-core 0.1.6 (setFloating / setFloatGeometry / setWindowOrder), bumped here.
· SecurityService
Serve Remote Terminal — a real shell over a WebSocket
Bridges a browser to a PTY on the member's own dev machine. Binary frames are the terminal, text frames are control; on the far hop an HTTP 101 onto a length-prefixed stream reaches the agent on the port the network policy already permits, so no Cilium change is needed. The first WebSocket in this service, and deliberately a raw one. STOMP is a message broker over a socket and what travels here is a byte stream between a browser and a shell — an envelope around every keystroke buys nothing. AUTHORIZATION HAPPENS BEFORE THE SOCKET. A browser cannot put a header on a WebSocket constructor, so rather than teach the handshake to parse ***, an ordinary authenticated endpoint makes the whole decision with the session in hand and hands it forward as a single-use ticket that is worthless 30 seconds later and again after one use. That mirrors the KamoDesktop link, which was hardened for exactly this reason: a replayable token sits in history and in Ctrl+Shift+T, and a shell is worse in that respect than a desktop, not better. The handshake reads nothing else from the client — not a username, not an account, only a size hint the agent clamps anyway. The Linux account comes from credentialsFor(<the caller's own member id>), so a request cannot name somebody else's shell, and Origin is pinned to the host being addressed. No password is read at all: the agent is root and drops into the account, so the reversible credential Guacamole needs is never touched on this path. Audited before the fact, synchronously, into the same Access Log that already answers "who signed in from where" — with IP, geo and user-agent, at HIGH severity. A row that only appeared on success would be missing exactly the cases that matter. Eight terminals per member, because each is a PTY and a thread on a shared VM that has been taken down by memory pressure before. Closing the socket does not kill the shell; the agent runs it under tmux so a refresh or a dropped connection reattaches to the work still running. 21 tests: single-use, expiry either side of the boundary, unknown-token silence, per-member cap and its release, non-sequential tokens, and the frame codec including one-byte-at-a-time delivery, an absurd declared length, and reading the upgrade's status line without swallowing the stream behind it.
· KlusterServices
Give the agent a PTY endpoint for Remote Terminal
GET /pty upgrades the connection to `kamo-pty` and becomes a shell: one PTY per browser tool window, `su - <user>` into `tmux new-session -A`, frames pumped both ways over the raw socket. A 101 rather than a WebSocket. The caller is SecurityService holding the bearer token, not a browser, so there is no reason to hand-roll RFC 6455 framing to stay stdlib-only. After the status line it is `uint32 length | uint8 type | payload`, and ThreadingHTTPServer's thread-per-connection is exactly the shape this needs. tmux, not a bare shell, and that is the whole reason it is here. A raw PTY dies with its socket, so a page refresh, a closed laptop or a wifi blip would kill a running build — and kamo-internal restores tool windows on load, so a restored terminal would come back holding a corpse. `new-session -A` makes first-connect and reconnect the same command. Security follows the file's existing rule: the username arrives already resolved from the member's own session and is re-validated against USERNAME_RE and the reserved list before any process starts, session ids are checked because tmux reads `.` and `:` as address syntax, sizes are clamped because they allocate a framebuffer on a shared machine, and a disabled account is refused even though `su` would still work for it. No password is read at all — the agent is root and drops to the account, so unlike the Guacamole path the stored credential never leaves the database. TERM is forced through `env` because `su -` rebuilds the environment from scratch, and a terminal that comes up as `dumb` has no vim, htop or less. Abandoned tmux sessions are pruned when that member next connects, which needs neither a timer nor a list of every account on the machine. 24 tests: argv shape, reserved/invalid refusals, size clamping, frame codec including a frame split across reads and an absurd declared length, control frame handling, and the reaper's four cases. 107 pass.
· kamo-shared-library
Record a dev-machine terminal in the access log
A member opening a shell on desktop-1 now has an event type of its own. That machine holds a cluster-admin kubeconfig and its accounts have passwordless package management, which is root-equivalent — "who had a shell there, from which address, and when" has to be answerable from the same Access Log that already answers it for logins. Safe to append: system_access_logs carries no CHECK constraint (verified in production today) and event_type is varchar(30). Contrast PhiResourceType, whose Hibernate-emitted value whitelist ddl-auto: update will never widen.
· SecurityService
Send the company alongside the contact wherever a record is labelled
A record that is both an organisation and a person is named "Harmony Home Loans (Nickolas McGarry)" — the company leads, the contact qualifies. That rule lives in the browser's entityLabel(), so these endpoints stop picking one half and send both. - The /leads grid timeline stamped each entry with the contact and fell back to the company. Same lead, therefore, read as a person on its timeline and as a company on the grid that produced it. - The lead credit/assignment history resolved the contact only. Resolved here rather than in the shared history service because this is its only renderer, and one findAllById over the page's distinct leads is a single query. - An account's Leads tab was sending {uid, organizationId} and nothing else, so every column read "---" and every row linked to /leads/view/undefined. It now sends id (which is what that table keys and links on), the two name halves, status and dateCreated. The lead's e-mail is gated the way lead contact detail is everywhere else: your own lead always, anyone else's only with VIEW_LEAD_CONTACT_INFO_OTHERS.
· kamo-shared-library
Send an account's company alongside its contact for customer pickers
Six customer pickers read AccountSummaryDTO — the order editor, the service-job form, the subscription detail and wizard, and the pipeline signups tab — and every one of them named a customer by whoever the point of contact was. Somebody choosing which customer a job or an order belongs to is looking for the organisation. displayName is now the contact ALONE. It used to carry a "(#123)" suffix, which made sense while it was the whole label and produced nested brackets the moment the company led it — "Harmony Home Loans (Nickolas McGarry (#123))". The uid is already a field of its own for any picker that needs to separate two customers with the same name, and the browser composes the halves through one entityLabel() that every other customer-facing screen goes through too.
· kamo-internal
Communication View — the whole filtered book as one timeline
Three changes to /leads. Quick search moves into the hero, above the CRM Settings links. In the filter row it was the last control on a line of eleven dropdowns, findable only by scanning past all of them. LeadManager still owns the value's journey into the query and into the session storage that restores the filter bar on the way back from a lead — the box moved, nothing else did. A flip switch, centred in the pagination row: Lead View / Communication View. Two named positions rather than a Switch, because neither of these is "off" — they are two ways of looking at the same filtered set, and a control naming only one would leave the reader guessing what the other position does. Communication View replaces the grid with the same timeline a lead's own Communications tab draws — same component, same six sub-tabs, same entry renderers — scoped to whatever the filter bar currently matches. Every entry carries a chip naming its lead and linking into it: cross-lead, a row that does not say whose call it was is unreadable, and finding the one conversation worth opening is the whole reason to scan the list. No note composer, deliberately: a note has to be about one lead, and a box that could not say which is a trap rather than a convenience. Details that are load-bearing rather than incidental: - The grid's page fetch is SUSPENDED in Communication View. GET /api/security/leads writes one PHI access row per lead it returns, and fetching a page to render a screen showing none of them would put reads in the audit trail that nobody performed. - The filter string handed to the timeline is debounced on the grid's own delay. The grid debounces its request; the timeline cannot — it refetches whenever the string changes and fires two requests per change, so typing "smith" was ten round trips, each writing a PHI search record, none of them aborted. - page/size/sort are stripped from it. size actively collides, because the timeline sets its own. - The pager's two tracks stay MOUNTED when they empty. A display:none child is not a grid item at all, so hiding them would have left the switch as the first item and slid it into the left-hand track, off centre.
· kamo-internal
Add JustCall as a phone server type
Adds JustCall to the picker at **************** with its API key and secret, a contacts-import toggle, credential status on the providers tab, and the logo. The interesting part is dialling. JustCall publishes no SIP registrar and no WebRTC credential to third-party applications, and its only outbound-call endpoint drives an AI voice agent rather than a person — so the backend reports voiceCalls=false, which useSoftphone already gates registration on, and no softphone is built. But a member on JustCall can place a call perfectly well, just not here, and "voice calls not available with current provider" would be a lie. dial() therefore hands the number to JustCall's own web dialler. That handoff is its own module rather than a branch inside the hook: it is a lib concern, and putting it there makes it testable. It uses the web dialler rather than the justcall: protocol the desktop app registers, because a browser cannot tell whether that app is installed and an unhandled protocol navigation does nothing at all — a member without the app would click dial and watch nothing happen. ProviderCapabilities gained providerType so a false voiceCalls can be acted on rather than merely reported: for most providers it means "no dialling", for this one it means "dial over there". Mirrors SipCredentialsResult.provider, which exists for the same reason. The new coverage test is the frontend twin of PbxProviderBillingCoverageTest — a provider nobody remembered to label does not fail loudly, it renders as a blank card in the one screen an admin uses to choose it. Verified: tsc clean, all 14 guards including check-i18n-keys --require-messages, 4,938 vitest tests. `next build` was NOT run against these changes: another session held this working directory with its own concurrent builds throughout.
· SecurityService
A communications timeline over the whole /leads filter, not one lead
GET **************** takes the SAME query string GET the lead detail page draws — across every lead the filter bar matches. "What has been said to the leads I am working" is a question about a set, and until now the only way to ask it was to open them one at a time. The filter translation moves to LeadGridFilters so both endpoints resolve a query string identically; a second copy would drift, and the way it drifts is a timeline covering different leads from the grid that produced it. Two different bounds, for two different reasons. Calls, voicemails, texts and e-mails are unbounded: LEAD_COMMUNICATIONS carries ORG_ID and is indexed on (ORG_ID, OCCURRED_AT), so one keyset query walks the org's correspondence newest-first and tests each row against the grid predicate as a correlated EXISTS — the same cost whether seven leads matched or seventy thousand. Notes and chat cannot be: they live in MEDIA_OBJS, which carries no org and no lead, and the only join back is LEADS.STREAM_GUID, which is not indexed. Those two resolve the matching leads first, capped at 1000, and the response says when it capped rather than quietly dropping history. Authorisation is the lead timeline's, applied in SQL. An entry carries a lead's own number or address plus message content, so LeadCommsReadGate now also states its rule over the three shapes a lead can have and emits the matching predicate. Writing it a second time is where the bug was: the first draft read "unassigned needs VIEW_UNASSIGNED_LEADS" and missed that refusal() falls THROUGH that check to the contact-info one, which would have put every pooled lead's correspondence in front of a member holding only the pool right. The two forms are now pinned to each other exhaustively — every right combination against every shape. Unlike an account's rollup this does NOT collapse an event that reached two matched leads. Two reasons pointing the same way: each row names the lead it belongs to and "this call reached both" is a fact about the book being worked, and collapsing rows out of a single fixed-size page can leave the survivor last twin as a fresh entry the client cannot de-duplicate. The criteria queries are pulled into LeadGridCommsQueries as pure functions so LeadGridCommunicationsSqlTest can render them against real Hibernate metadata. That matters here more than usual: the grid's own specification already emits EXISTS subqueries, and this nests it inside another one — which is exactly where this service's Hibernate 6.2.13 and the shared library's 6.5 differ.
· VOIPService
Wire JustCall in through the phone provider abstraction
JustCall (api.justcall.io/v2.1) as a full PhoneProvider: call history, texts, voicemail, recordings, the agent directory and availability, plus signed account webhooks and an optional contacts import. Auth is one account-wide key/secret pair sent as the raw "key:secret" Authorization value — not a Bearer token, which authenticates nothing and returns a 401 that reads like a bad credential. Three shapes of JustCall's API drive the design: * Voicemail is not a resource. It is a call whose call_info.type is "Voicemail", carrying its transcription inline and its audio behind the same recording endpoint as any other call — so getVoicemails is getCallHistory with a filter, and there is no read state to mark. * There is no softphone credential. JustCall publishes no SIP registrar and no WebRTC token to third parties, and its only outbound-call endpoint drives an AI Voice Agent rather than a person. voiceCalls is therefore false, which is what useSoftphone already gates registration on, and getSipCredentials refuses with an explanation instead of half-registering. * The directory is read-only, and there is no device concept, so those keep the interface's throwing defaults rather than pretending to write. Webhooks are registered per instance at **************** one subscription per event type. JustCall's HMAC-SHA256 covers the registered URL, so that query string is inside the signed material and cannot be rewritten to land one org's events on another's phone server. The URL is recorded in config_json at provisioning because verification has to sign the same string JustCall did — rebuilding it from configuration would start failing silently the moment the base URL changed. Inbound texts and call events reach the paths RingCentral already uses. VoipMessageService's handler was RingCentral-shaped throughout, so its parsing is split from its ingest: both rails now share the dedupe, keyword interception, conversation upsert and lead linking. That sharing is deliberate — the keyword step is what records an opt-out, and a second rail growing its own copy of the sequence is how one ends up not honouring STOP. SmsKeywordService's reply switch gained the new rail for the same reason: it is a statement switch, so an unlisted constant would have answered nobody. MMS is by public URL only — JustCall accepts no uploaded bytes — so an attachment is parked behind a short-lived unguessable URL on this service and that link is handed over. Deliberately not a presigned object-store URL: MinIO here is **************** a presigned URL is signed for the host it names, and JustCall's fetcher cannot resolve an in-cluster address — it would 404 for them while working perfectly from inside the cluster. This service is already publicly reachable because it receives their webhooks. Contact import is off unless switched on: a customer's address book is per-member access controlled, and importing it the moment a key is pasted would be a surprise. Contacts are owned via the JustCall agent_id the extension sync already recorded, and any whose agent maps to no Kamo member is skipped rather than parked somewhere shared. Persistence goes to EmailService, which owns the contact tables. JustCall's AI call summary, sentiment and score ride on the same call read and are surfaced on CallLogEntry. Not persisted: PHONE_CALL_LOG has no such columns, the provider returns them on every read, and adding columns would cost a DDL window for a duplicate of their store. No DDL in this change.
· EmailService
Internal endpoint for importing contacts from an external system
VOIPService's JustCall integration needs to land a customer's contacts in the right member's address book, and this is where contacts are written: a Contact maps only scalars, with its emails, phones, addresses, orgs, URLs, IMs and relationships in seven tables with no cascade, reachable only through ContactDetailStore. A caller writing those tables from another service would silently drop every phone number — the one field a phone integration exists to deliver — so the persistence stays here and the caller posts to it. Ownership is the load-bearing part. CONTACT.OWNER_ID is NOT NULL and points at users, and a ContactBook's owner IS its access control (an ownerless book belongs to nobody and 404s for everyone), so a batch must name the member its contacts belong to and is refused outright when it does not. Each source gets its own book behind a ContactIntegration row rather than being mixed into "My Contacts": synced rows and hand-typed rows have different lifecycles, and the member can hide or drop the imported ones without touching their own. Upserts match on uid, which the caller namespaces to its system. UID is unique per book, so that is also what makes a re-sync an update instead of a second copy of everything. Mounted under /api/internal, guarded whole by InternalAuthFilter — there is no member session behind these calls, so without that filter this would be an endpoint for writing arbitrary contacts into arbitrary members' books.
· kamo-shared-library
Add JUST_CALL as a phone provider type
Appended at ordinal 5. **************** is EnumType.ORDINAL and carries no CHECK constraint, so appending is safe and inserting anywhere else would silently re-point existing rows at a different provider — PbxProviderBillingCoverageTest pins the order from the other side. Billed THIRD_PARTY: the organization holds its own JustCall subscription (their API needs at least a Team plan) and JustCall carries the calls, so this is an integration charge, not hosting. PbxBillingClass.of has no default arm, which is what forced the decision rather than letting a new provider bill nothing forever.
· kamo-internal
A paper colour per block, and blocks that fit their text
BLOCKS NOW FIT THEIR CONTENT. The reader's blocks were all 200px tall whatever they held, because richTextExtension configures the editor with minHeight: 200px — the article composer's shape, where the editor IS the page. Switching the reader to RichTextView removed it; nothing in the pane clamps a block's height now, so it is exactly as tall as its words. THE COMPOSER'S EDITORS GROW INSTEAD OF SCROLLING. Same 200px, plus overflow: auto on the container, meant every block opened three lines too tall and then handed itself a scrollbar rather than growing — a scrollbar inside a block inside the page's scroller. KbArticleEditor gains `autoGrow`: the container stops scrolling, the writing area starts at one line's height, and the root sizes to content instead of filling its host. Opt-in, so the article composer keeps the shape that is right for a page-sized document. PAPER COLOUR. Each block carries one, picked from NoteColorMenu — the same menu and the same palette the notes and the launchpad's panes use, so a swatch means one thing across the product rather than three. The colour is stored as the palette ID and resolved through resolveStickyColor at render, so both the composer and the Interaction Center paint from one source and a later palette correction reaches scripts written today. In the reader a coloured block keeps its colour whether or not it is the current one. "Where am I" is already carried by the border, the lift and the spine; dimming the paper as well would make the author's choice look like a rendering fault.
· kamo-internal
Add the JustCall provider logo
The VOIP logo folder had FreePBX, Microsoft and RingCentral but no JustCall mark, so any surface listing JustCall as a provider had nothing to render.
· kamo-shared-library
A paper colour per block
**************** holding a palette ID — "lemon", "glass" — and never a hex value. Stored the way notes and launchpad panes store theirs, for the reason that matters rather than for consistency's sake: the palette is defined once on the client (STICKY_PALETTE) and every surface resolves an id through resolveStickyColor. Persisting hex would freeze today's shade into the row, so a later correction to the palette would fix the notes and quietly leave the scripts on the old colour — and the same swatch would then mean two different things depending on which screen it was picked from. Null is no colour: the block wears whatever surface it sits on. A blank or whitespace value is normalised to null on the way in, because "" is not "no colour" to resolveStickyColor — it hands back the palette's first swatch, so an author who cleared the colour would have got lemon. Carried through the full block replace like every other field, and covered there: the colour survives an edit, and a cleared one comes back null.
· EmailService
Let a sent message carry images inside its body
The body had four possible shapes and send() could only build three. The multipart/related branch — the one that puts an image where the HTML's cid: points at it — sat behind the attachment branch, so a message with both a pasted image and a file lost the image on the way out: the body kept a cid: reference to a part that was no longer in the message, and nothing said so. It was also unreachable in practice, because only the transactional path ever set inlineImages; nothing a member sent could get there at all. applyBody now builds whichever shape the content calls for — html alone, related when the body references images, mixed when there are attachments, mixed wrapping related when there are both — and a calendar invite keeps its images by offering the related as the HTML alternative. reply and forward go through send, so they inherit all of it. /send takes the images as their own binary parts, each named for the content id the HTML already references. Base64 inside the JSON part would cost a third again in size against the smallest ceiling in the chain, so a screenshot or two would be refused for a reason the member could do nothing about. A part's name becomes a Content-ID header, so InlineImageParts matches it against the exact shape the composer mints and refuses everything else — a wrong one fails the send rather than dropping quietly and leaving a hole in the body.
· kamo-asterisk-support
Reconcile per-extension WebRTC blocks instead of hand-editing a roster
An extension created through FreePBX's API gets no WebRTC block, so it registers over WSS and then has no DTLS or ICE — the browser softphone shows a connected call carrying no audio. Those settings cannot be expressed anywhere FreePBX will accept them: `dtls_cert_file` and `transport` are not columns in the `sip` table and not fields on the `addExtension` mutation, so they have to be `[<ext>](+)` appends in **************** Until now that file was a hand-maintained roster in a ConfigMap, which meant every new extension was silently broken until somebody remembered to edit it and redeploy (8888 sat unlisted for months). Extension 8777 was the latest. WebRTCReconciler reads the extension list out of the FreePBX-generated pjsip.endpoint.conf and keeps a marker-delimited managed section in step with it, every 30s. Deleted extensions lose their block automatically — a (+) block for a section that no longer exists makes Asterisk log "Section does not exist" on every reload. It only fills gaps: any block written outside our markers (the KlusterServices roster, an administrator) is left exactly as it is, never duplicated and never taken over. Only all-numeric endpoints are touched, so trunks are not pinned to a WS transport with DTLS forced on. A missing pjsip.endpoint.conf is a no-op rather than "zero extensions", which would otherwise strip WebRTC from every extension that has one. OFF by default (MANAGE_WEBRTC_ENDPOINTS), on for k1m1. The block is exactly wrong for a SIP desk phone: it forces DTLS-SRTP the phone cannot do and pins it to a transport it does not speak. Two things measured on the live k1m1 box rather than assumed: - The reload has to be `module reload res_pjsip.so`, not `pjsip reload`. After adding a `[8999](+)` block, `pjsip reload` left the endpoint on webrtc=no; one module reload applied it, and one module reload also reverted it when the block was removed. Using the CLI's own `pjsip reload` here would write a correct file and leave the running config wrong. - A (+) block can only ADD options, never override. Appending `direct_media=yes` to an endpoint whose generated config said `direct_media=no` left it on `no` — first value wins. (`webrtc=yes` is the exception only because it is an additive shorthand, not a stored field.) Anything that must CHANGE a generated value has to be fixed in the FreePBX database. Verified against the real /etc/asterisk: correctly picked out the one uncovered extension from 23, skipped the Erik_Primary trunk, left the existing roster byte-for-byte identical, and was a no-op on the second pass.
· kamo-internal
Impersonate, so an operator can open an org as one of its members
Shown beside Enter on every /network view for a platform administrator holding the new IMPERSONATE right. A different right from SYSTEM_USER and deliberately not implied by it: entering as the System User is a visible, separate identity a tenant can see in their own member list, while this produces a session nothing downstream can tell from that person signing in themselves. The picker is MemberPickerDialog — the same popup the /leads Agent filter opens — unchanged. It already owns the search, the paging, the keyboard handling and the way an unpickable row explains itself. What is new is its `fetcher`, which replaces the directory endpoint outright and points at the target organization's members, because the default directory is the session's own org and that is precisely the wrong list. Nobody is filtered client-side. The server marks a god account, a platform administrator or an off-boarded member unselectable with a reason, and the dialog's reason table grew to cover them: hiding those rows would leave an operator searching for a colleague plainly in the organization and getting nothing back, which reads as a broken search rather than as a refusal. The tab is claimed synchronously inside the picker's own click, before the await, and its sessionStorage cleared — the same dance handleEnter documents, for the same two reasons: a popup opened after an await is one the browser never saw anybody ask for, and a same-origin window.open CLONES this tab's storage, session id included. Also adds the icon the Platform Admins tab needs for the new right; tsc caught that, which is what RIGHT_ICON being an exhaustive Record is for.
· SecurityService
Enter a tenant organization as one of its members, from /network
A second door beside the same-org god-mode one. Gated on the new IMPERSONATE platform right via @RequirePlatformRight, where the existing door is gated on the session carrying GD. Kept as two handlers rather than one with a flag. Their caller gates are genuinely different, and @RequirePlatformRight has no in-handler variant by design — a gate you have to remember to call is one that eventually gets forgotten on the endpoint that mattered. Who may be ENTERED AS is a separate question, and ImpersonationTargetRules answers it as a pure function over resolved facts: refused for the top-level org, yourself, a membership with no user, the System User, a god account, a platform administrator, or anyone who could not sign in themselves. The same function decides whether a row in the picker is selectable, so the list cannot offer somebody the endpoint would turn away. The same-org door deliberately does NOT share those rules. It operates inside the caller's own organization, which for a god admin is usually the top-level one, and on people who are frequently administrators themselves — both hard refusals here. Folding the two together would break the settings page or quietly widen this one. **************** fills the picker. It is the one endpoint in that controller naming an organization other than the session's, which is why it is a separate handler rather than a parameter on /search: the class's promise that /search cannot be widened by accident stays true. Refused people stay in the list with a reason rather than vanishing from a search for a colleague who is plainly in the org. workspaceHostFor moves off EnterAsController into WorkspaceHostResolver, since both doors need the identical answer. Two copies of "which host is openable" is how one keeps sending operators to a certificate interstitial after the other stopped. Its tests moved with it.
· kamo-shared-library
IMPERSONATE, so an operator can enter a tenant as one of its members
Appended to PlatformRightType rather than inserted: getSortOrder() returns ordinal(), so a right added in the middle renumbers every right below it in the console. Grants are @Enumerated(STRING), so appending changes nothing already granted. Deliberately not folded into SYSTEM_USER. That right enters as a visible, separate identity a tenant can see in their own member list and audit trail; this one produces a session nothing downstream can tell from the member signing in themselves. Different questions, different blast radius, so an operator entitled to the first is not thereby entitled to the second. ReportVisibilityTest pinned MANAGE_REPORTS as the LAST value, which appending breaks while proving nothing — the invariant that matters is that nothing was inserted ABOVE it, so it now pins the ordinal instead. Two batch repository lookups for the picker behind it: the memberships a set of users hold in one organization, and which of a set of members are platform administrators. Row-at-a-time that is two queries per name, on a control that re-runs on every keystroke.

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