Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-shared-library

    An organization can bring its own OAuth2 client

    Until now the client id and secret for every OAuth provider came from one platform-wide registration, so an org could only connect what Kamo had registered — and a platform operator disabling a provider left every org on it with no way through at all. ORG_OAUTH_CLIENTS holds a full registration per (org, provider): the same column shape and the same PlatformSecretCipher as the platform row, so both decrypt through one path into one ResolvedOAuthClient. Resolution order is the org's own ENABLED row, then the platform row. An org that never fills one in resolves exactly as it did before, so no existing connection changes behaviour; an org that does has opted in by the act of entering credentials, which is why this needs no second "which app?" flag on every integration. Turning the org row off rather than deleting it hands that org back to Kamo's app with its credentials still on file. ResolvedOAuthClient now carries its origin, so a consumer can report which app a connection was actually made with.

  2. · kamo-marketing

    A product gallery at /screenshots

    A page for showing the product as it actually looks. It is built around a data module rather than markup, because more screenshots are coming and adding one should not mean editing a component: drop the WebP in public/img/screenshots, add an entry to app/lib/screenshots, add its copy to the dictionary. The page has two halves and each one disappears when it is empty. Featured shots get a full showcase section — copy, capability chips, the picture in window chrome, and a numbered key to the callouts pinned on it. Everything else falls into a filterable grid below, whose category chips only render once there are two categories to choose between. Today that means one showcase and no grid; it fills out on its own as shots are added. Clicking any picture opens a viewer: arrow keys and thumbnails to move between shots, Escape to leave, focus trapped and then handed back to the picture that opened it, and a 1:1 zoom that pans with the stage's own scrolling. The viewer serves the WebP on disk rather than an optimized variant — a responsive srcset is chosen from the viewport, so zooming one of those to 1:1 shows a 3231px screenshot upscaled from 1920 and every label in it goes soft. Three things worth knowing: - images.qualities gains 75. Next 16 does not reject a quality missing from that list, it SNAPS it to the nearest listed value, so every screenshot was being re-encoded at 50 with no warning anywhere. That is right for a photograph under a dark scrim and wrong for a picture made of 12px UI labels. - The callout pins are sized to their dot, with the label out of flow. Laid out as a flex row, `translate(-50%)` centres the pin on dot plus an invisible 15rem label, and every pin lands several percent off the thing it points at — by a different amount each. - The viewer sits at z-index 100000. The offer banner is at 99999 and the navigation at 10001; a modal the fixed header paints over is not a modal. Registered in the Product menu, the footer, /site-map, both sitemaps and llms.txt, with an ImageObject feed so the pictures can be found on their own in image search. Verified in a production build: the route is still prerendered, and open/zoom/Escape/focus-return were driven with real input events at desktop and phone widths.

  3. · kamo-internal

    Start one from the Interaction Center tab

    The tab listed terminals and gave a member no way to open one — the only starting points were the Linux Desktop submenu and the navigator's header, neither of them where they were looking. `onNew` is optional rather than always-on because the two surfaces put the action in different places: the navigator has a header and keeps it there beside refresh and collapse, while the tab has no header to hang it on and needs it in the list. Rendering it in both would put two of the same button inches apart. A row at the head of the list rather than a floating button — it sits where a new terminal would appear and scrolls with the list instead of covering a row — and a proper button on the empty screen, which is where it matters most. Not offered when the dev machine is unreachable: inviting somebody to start a terminal that cannot start is an invitation to a failure, and the empty state already says why. The empty-state copy no longer points at the Linux Desktop menu, which was the long way round once a button was sitting next to it. It says the thing a member cannot see instead: a terminal outlives the window it was opened from. Renamed rather than edited, because translate.ts queues a key only when it is missing. Writing the test found a real defect: the row rendered in the collapsed 40px rail too, where a labelled row cannot fit. Six tests pin where the action appears and the three places it deliberately does not. 417 test files / 5252 tests, all 14 guards, tsc and the production build clean.

  4. · kamo-register

    Remember which job advert brought a candidate here

    The public careers site links to /team?jobId=<uid>. Registration is several steps, crosses hosts and bounces through email verification, so the uid is parked in a cookie scoped to the shared apex rather than threaded through the flow's own state, which would lose it at the verification detour. The workspace Careers tab reads it and opens that advert. The Domain is derived from the serving host, never hardcoded: a Domain the browser considers unrelated to the current host is not ignored — the entire Set-Cookie is rejected, silently, with no console warning. Wrapped in Suspense because useSearchParams opts its subtree out of prerendering; without the boundary the whole page de-optimizes for a component that renders nothing.

  5. · kamo-internal

    Publish an advert externally, and pick the candidate back up

    Three changes, all in service of the new public job board at careers.<apex>. The posting editor gains "list on our public careers site". It sits with status and the dates rather than with compensation because it belongs to the same question they do — who can reach this advert, and until when — and an org turning it off is making a hiring decision, not a formatting one. Defaults on. /setup/dns gains the careers card. DomainAliasContractTest pins this catalogue against SecurityService's KnownAliases: a label the studio requires but that list omits gets no verify-dns status at all, and the studio renders a missing status as "pending", so the card sits at "awaiting DNS" forever with nothing anywhere reporting an error. That is what happened to media for four months. And the Careers tab finishes the loop the public site starts. A candidate who found a role there is sent to **************** which parks the uid in an apex-scoped cookie; several redirects and a different host later they land here, and without this they are dropped on a board and have to find the advert again — which is where a hiring funnel loses people. The cookie is cleared whether or not it matched, with the Domain it was written with, because a cookie's identity is (name, domain, path) and an expiry sent without it removes nothing and reports nothing.

  6. · DocsService

    The anonymous API behind careers.<apex>

    The public job board reads this. It is the only surface in Careers with no session, so the safety comes from the QUERY rather than from a rights check: PublicCareersService loads through a repository predicate that filters on publishExternally and the two visible statuses, so an archived or internal-only req is never fetched — not fetched and then blanked, which is one forgotten field away from publishing a confidential backfill. A miss is 404, never 403; refusing by name confirms the posting exists. An unpublished pay range is likewise never WRITTEN to the DTO rather than blanked at render time, because a number on the wire is one devtools tab away from being read. Filtering and faceting happen in memory over the org's visible set. That is a deliberate trade: a careers board is tens of adverts, not a catalogue, and holding the set means the facet counts are exact and free-text search can reach into the advert BODY — flattened through RichTextValues, which is the only honest way to search a Lexical document. A LIKE over the raw column would match "paragraph" in every advert. Also: listedAt is stamped when a posting ENTERS available and never on an ordinary edit, so re-listing restarts the applicant window while changing a salary line does not silently zero a count HR is reading. CareersPublicController is a separate class from the member one on purpose. Adding a permit-all mapping beside gated ones puts the leak one copy-pasted annotation away; here the absence of a guard is the class's stated contract. 23 tests: what the anonymous surface may see, and the listedAt invariants.

  7. · kamo-shared-library

    The two columns a public job board needs

    publish_externally decides whether an advert reaches careers.<apex> at all. It is a separate axis from status: an internal-only posting is fully AVAILABLE and must still never be indexed — a confidential backfill, or a role an org wants to fill from within first. Defaults TRUE so an org that turns its careers site on does not have to re-open every advert to populate it. listed_at is the moment an advert most recently BECAME available, and it moves on every re-list. postedOn deliberately does not: that is the date a candidate is shown, stamped once so a role that paused for a fortnight does not come back claiming to be new. The public board says "eleven people have applied", and it has to mean this hiring round — counting against postedOn would fold in a round that closed months ago and tell a candidate the queue is far longer than the one they are joining. The column carries BOOL DEFAULT TRUE in its columnDefinition, which is load-bearing rather than tidy: this is an ADD COLUMN on a table with rows, and Postgres refuses NOT NULL without a default — the KamoInitializer run would fail and take every other pending change with it. Three queries: the public visibility predicate (org + publishExternally + the two visible statuses, so an archived or internal-only row is never LOADED rather than loaded and blanked), its by-uid twin, and the per-posting applicant count bounded to each posting's own listed_at.

  8. · SecurityService

    Serve an org's branding from the host it is browsed on

    A white-label sign-in screen is reached at login.<customer domain> with no ?org= on the URL and nothing in sessionStorage, so the front end has no way to name the organization whose logo and colours it must paint. It fell back to deriving a theme folder from the hostname, which only ever answers for organizations that predate the alias migration. /org/public/by-host/{host} resolves it properly: OrgResolutionService.byHost, the same rule the sign-in host uses, returning the alias the theme folder is actually named for. Read-only on purpose — the sibling /org/domain/{fqdn} auto-provisions app features as a side effect, and an unauthenticated endpoint reachable by putting any hostname in a URL must not write. careers joins KnownAliases, SSL_PROBE_LABELS and the reserved alias list: byHost reads a leading label that is not a known platform alias as an organization's own alias, so careers.<domain> resolved as "the org aliased careers" until DomainAliasType.CAREERS landed (shared-lib b692cfc4).

  9. · kamo-shared-library

    Careers is a platform alias, not an org's own

    OrgResolutionService.byHost reads a leading label that is not a known platform alias as an ORGANIZATION's alias within the domain behind it, so without this entry careers.acme.com resolves as "the org aliased careers, hosted by acme.com" - nothing, or somebody else's org that claimed it. Inserted before VPN rather than appended. Safe: the enum is never persisted (no @Enumerated anywhere in the fleet, only host resolution in **************** so no row is relabelled by the ordinal shift.

  10. · kamo-internal

    Footer Badges — a row of images under the signature

    The Content tab's 'Footer Image' was one picture chosen from three fixed sources and sized by a percentage. It is now 'Footer Badges': the same three plus anything the organization uploads, ticked and ordered per source, drawn at one shared height with one gap between them. The library is org-wide and the selection is per source, so an image uploaded while editing the individual signature is offered under all three and printed only where it is ticked. What is stored is IDS; the URL and label are resolved against the library on every render, because the picture behind member-avatar is the SENDER's and a URL saved beside the id would be the stale copy. An id the library no longer has is dropped rather than rendered, so a badge deleted in another tab never becomes a broken glyph in a footer. A percentage was right for one image and is wrong for a row: differing intrinsic sizes never line up. So the scale slider became a HEIGHT, every badge keeps its own aspect ratio, and where the stored size is known the img carries an explicit width too — Outlook ignores width:auto and stretches to the cell. The gap is left padding on every cell but the first, so a single badge gets none: a lone image nudged 15px right of everything above it reads as a misalignment, and the spacing control is hidden in that case for the same reason. Uploads are downscaled to 150px tall before they are sent, through the new resize-height endpoint that keeps the file's format and transparency — the existing resize-bg re-encodes to lossy WebP, which half the mail clients in use cannot display at all. Badges are addressed at api.<apex> of the apex being browsed, so a white-label tenant's mail carries its own host and the page CSP (img-src … https://*.<apex>) does not silently refuse the preview. Dirty-state now compares a FINGERPRINT rather than raw JSON. headerImageUrl, signatureImageUrl and each badge's url/label are looked up after load, on their own schedule, and the badge library is a separate request — so comparing raw JSON lit the Save button on page load with nothing touched, and put the unsaved- changes dialog in front of anyone who then switched source.

  11. · InitializerService

    Email_signature_badges, and the columns that order them

    The footer badge library plus footer_badges / footer_badge_spacing / footer_badge_height on email_signature_templates. A runner and not just ddl-auto for the usual reason: this service runs ddl-auto: update and would create all of it from the entities, but EmailService — the only service that reads them — runs ddl-auto: none, so the schema has to exist before that service ships. footer_badges is deliberately NOT backfilled from footer_image_source. NULL is what makes a row say 'written before badges existed', which is what sends the reader to the legacy column; filling it in here would erase that distinction and would have to guess between 'none' meaning an empty selection and a real image meaning a one-badge one. Only the reader has the enum to tell those apart. The ADD COLUMNs are expected to be no-ops on a database where Hibernate got there first — and IF NOT EXISTS swallows their DEFAULT clauses with them, since Hibernate never emits one. The SET DEFAULTs are stated separately for that reason, where nothing can swallow them.

  12. · EmailService

    A library of footer badges, and the row each scope prints

    The signature footer went from one image to a row of them. The library is org-wide; the selection and its order are per scope, so an image uploaded while editing one signature is offered under all three and printed only where it is ticked. Two surfaces with deliberately different auth, which is why they do not share a path. /api/email/signature-badges needs a session — it is the settings screen. **************** does not and cannot: it is what a RECIPIENT's mail client fetches while rendering a message, from a machine that has never heard of Kamo. Only that second prefix is exempted in OTKPreAuthFilter, so the exemption cannot widen into 'anyone may edit an organization's badges'. It is addressed by random UUIDv4, cached immutably, and served under a CSP sandbox so an SVG opened directly cannot be a document carrying script. A delete is never one row. A badge removed from the library while COMPANY still lists it leaves that scope rendering an img at a URL that now 404s — a broken glyph in the footer of every message it signs, which nothing reports and which the member who deleted it never sees, because they are looking at the scope they deleted it from. So the same transaction strips the id from every template. ImageHeaderReader is the server's own read of the upload. ConversionService resizes before this point, but 'the client already did it' describes every request except the one worth refusing — and every library that would answer 'how tall is this' decodes the whole raster to do it, so a 10 MB PNG declaring 30000x30000 would allocate gigabytes in a pod sized for mail. These bytes are parsed, never decoded.

  13. · ConversionService

    Resize an image by height, keeping its format and transparency

    Backs the email signature footer badges, which are downscaled to 150px tall on upload. resize-bg could not be reused. It exists to make a wallpaper cheap to ship, so it re-encodes everything to lossy WebP and fits it inside a box — and a badge is composited into a message body by mail clients (every Outlook on Windows among them) that cannot display WebP at all, on whatever background the recipient's client paints. So the format that was uploaded is the format that comes back: PNG through ffmpeg's negotiated pixel format, GIF through palettegen/paletteuse with a reserved transparent entry (animation intact), WebP lossless, JPEG at high quality. An SVG is returned untouched — a vector has no pixel height, and ffmpeg cannot read it either. An image already inside the limit comes back byte-for-byte. Re-encoding 'for consistency' is lossy for JPEG, re-palettises a GIF, and would discard an animation nobody asked to lose; being too tall is the only reason to touch it.

  14. · kamo-shared-library

    The schema behind a row of footer badges

    The email signature footer held one image, named by footer_image_source and sized by a percentage. It now holds a ROW of them: the three built-in sources plus anything the organization uploads, ticked and ordered per scope, sharing one height and one gap. email_signature_badges is the org-wide library. The bytes live in the row rather than in MinIO because a badge has to be fetchable, forever and without a session, by a stranger's mail client — a presigned URL expires and an authenticated proxy 401s — and because it has to be genuinely DELETABLE, which an object in a store nothing is ever removed from cannot be. Uploads are capped at 150px tall, so a row is tens of KB. On the template, footer_badges is the per-scope ordered selection. NULL and '' mean different things and must not be conflated: NULL is 'this row predates badges' and sends the reader to footer_image_source, while '' is 'configured, and none'. The two legacy columns are left exactly where they are — they are the migration source, and nothing writes them any more.

  15. · kamo-internal

    Add the VOIP_CALL_EVENTS scope

    Lets an org issue kamo-asterisk-support a key for reporting ringing extensions, separately from VOIP_RECORDING_UPLOADS. Uploading a recording after the fact and making somebody's phone ring right now are different powers, and a key for one should not silently grant the other.

  16. · kamo-asterisk-support

    Report ringing extensions so a member's mobile rings

    The AMI client logs in with 'Events: off' and only sends actions, so nothing on a customer's FreePBX ever told the platform a call was arriving. That silence is what made a missed call on the mobile softphone unrecoverable: a handset whose SIP registration Android had reclaimed was simply not there, and no second signal existed to wake it. A SECOND AMI connection now listens with 'Events: call' and forwards DialBegin (ringing) and DialEnd (answered/ended) to the platform. Separate connection because the action client is a strict request/response pair -- unsolicited events would interleave with its replies and it would read somebody else's packet as its own answer. Details that decide whether this works: - Linkedid, not Uniqueid. Uniqueid is per channel, so the cancellation would arrive under a different id and never match the ring it is meant to end. - Only PJSIP/ and SIP/ endpoint channels yield an extension. Local/, IAX2/ and DAHDI/ legs all reach the parser and none of them is a member's extension. - CallerIDName is dropped when it is just the number repeated, which is what Asterisk fills in when the carrier sent no name. - Best-effort, no retries, a 5s timeout: this is on the critical path of a call ringing right now, and a report that arrives after the caller gave up is worth nothing. - The event endpoint is derived from UPLOAD_URL so an operator configures one URL and cannot point the two halves at different clusters -- a split that would keep uploading recordings while calls quietly stopped ringing. Needs an API key with the new VOIP_CALL_EVENTS scope.

  17. · APIService

    API-key surface for live call events from a customer PBX

    Companion to VoipRecordingUploadController, on a deliberately separate VOIP_CALL_EVENTS scope: uploading a recording after the fact and making somebody's phone ring right now are different powers, and an administrator issuing a key for one should not silently be granting the other. The instance is taken from the X-Phone-Server-Id header, never from the body -- the API key proves which org is calling, and letting a body field name the instance would let one customer's PBX ring another customer's phones. Also reserves /api/voip/calls/** in APIApp, the same carve-out /api/voip/ recordings/** already needed. Without it the wildcard forward shadows this controller and a ringing extension reaches VOIPService with no key or scope checked at all.

  18. · VOIPService

    Let an on-premise PBX report a ringing extension

    RingCentral and JustCall publish INBOUND_RINGING from their webhooks. FreePBX and KamoPBX publish nothing -- they are the customer's own Asterisk and nothing was watching it. That gap is where the mobile softphone's push wake was blind: with no ringing event there is nothing to wake a handset whose SIP registration the OS has reclaimed, so the call is missed and nobody finds out. POST /internal/voip/ring resolves the extension to a member and publishes the same voip.call.{orgId}.{memberId} event the webhook providers already emit. kamo-asterisk-support calls it from its AMI event stream through ApiService's API-key surface. The extension is resolved by scanning the instance's configs rather than through a derived query, so this needs no shared-library change to ship; an instance holds tens of extensions and it runs once per ringing call. An extension no member owns answers 200 {published:false} rather than 404. Ring groups, IVRs and desk phones with nobody behind them are ordinary traffic, and a PBX helper that starts logging errors for ordinary traffic is one an administrator turns off. /internal/voip is already on InternalAuthFilter's list and exempt from OTK, so no security configuration changes here. Non-persisting, like the webhook paths: InstanceSyncService stays the single writer of PHONE_CALL_LOG.

  19. · MediaService

    Ring a member's phone for an incoming call

    VoipStompRelayController relayed voip.call.* ringing events to the browser and deliberately never pushed them, reserving incoming-call alerting for 'the future APNS_VOIP/CallKit path'. That path now exists on the device, so this is its server half. INBOUND_RINGING becomes an INCOMING_CALL push; CALL_ANSWERED and CALL_ENDED become CALL_CANCELLED on the same sessionId -- without the cancellation the handset rings on at a call that was picked up on a desk phone. Both types bypass the presence gate and the rate limiter, and neither exemption is cosmetic: - Presence marks a member online for 300s after their socket goes quiet, so a phone backgrounded two minutes ago still reads as live -- and that is exactly the phone whose SIP socket Android has just reclaimed. Gating on presence loses the call. Sending anyway is safe: only the device can tell whether it is already ringing, and it drops the push when its own INVITE beat it there. - The rate limit keys on the collapse key, falling back to the TYPE. A call push has no collapse key, so it would be one ring per member per fifteen seconds with the second caller discarded, and a dropped cancellation leaves a phone ringing at nothing. FCM call messages get ttl=0 and no collapse key: deliver now or discard, and never let a cancellation evict the queued ring it is meant to follow. High priority is load-bearing rather than a nicety -- it is what grants the app its temporary exemption from the Android 12+ ban on starting a foreground service from the background, which is the window the incoming-call service needs to exist at all. APNs splits by kind: calls go only to an APNS_VOIP registration, on the .voip topic with push-type voip, no alert block and a 30s expiry (iOS punishes an app woken for a call it does not report to CallKit). Calls are refused on alert registrations and non-calls stay refused on the voip topic. Inert until an iOS build registers such a token.

  20. · kamo-internal

    Send to AI opens the terminal it runs in, and the tabs get icons

    A dispatch now comes back naming a terminal, and the console opens it. That is not a courtesy view of something already underway — the hand-off is PREPARED on the dev machine and starts when the window connects, so opening it is what sets session, not by this client, so the title in the list and the title on the window cannot disagree. An open VS Code is no longer a precondition for sending. It was a real one when a dispatch was a background session handed to an editor; the terminal hosts it now, and VS Code only decides whether the session ALSO turns up as a tab there. The dialog says so and sends anyway — telling an operator to go and open an editor would be advice to fix something that is not broken. The two preflight strings are RENAMED rather than edited: translate.ts queues a key only when it is missing, so changing a value in place would have left 21 locales describing behaviour that no longer exists. Interaction Center tabs carry icons — the chat bubble its rows already use, the terminal glyph on the nav option and every terminal row — so the strip reads as a set of places rather than four words. iconPosition="start" keeps them inline; MUI's 72px min-height for icon tabs is meant for stacked layouts and only adds a band of empty space here. 416 test files / 5246 tests and all 13 non-i18n guards pass. The i18n guard and tsc are currently red on another session's uncommitted work in this tree (FooterBadgesEditor keys, app/hr/careers) — neither is in this change and neither is on origin; every key this change uses resolves against a fresh dictionary clone.

  21. · KlusterServices

    Pin the session id, and link the terminal session into VS Code

    `--session-id <uuid>` is chosen at prepare time rather than left to the CLI, which is what makes the editor hand-off deterministic: the id is known before the session exists, so it can be handed over without watching a directory for a file to appear and guessing which one is ours. The hand-off waits for the transcript before opening the tab, because the URI handler opens an EMPTY tab for a session id that has not written one yet — worse than not opening one at all. It is backgrounded, because confirming means waiting Best effort in the strict sense, and it is meant to be: the session's home is the terminal the member is watching, and whether it ALSO appears in the editor depends on a VS Code window being open and rooted somewhere that lists it. A missing editor can never fail a terminal — the tests pin that. 7 tests: nothing attempted without an id, the wait is backgrounded, a confirmed session opens, one with no transcript does not, and an exploding editor is swallowed. 156 tests.

  22. · SecurityService

    Send to AI now runs in a Remote Terminal the operator can watch

    The dispatch prepares the hand-off on the dev machine — screenshots, video and the composed prompt written to disk — and answers with the terminal it will run in. Opening that window is what starts the session, so the console gets a live shell to watch rather than a background process it can only ask about. THE COMMAND NEVER TRAVELS. The agent is asked to PREPARE a dispatch and hands back an opaque token; this service remembers it against the member and the session, the terminal's ticket collects it, and the agent runs what it composed itself. Sending a command would have been simpler and would have made every provisioned member's terminal an arbitrary-execution endpoint. The token never reaches the browser either. It could have — the console could have been handed it and asked to pass it back — but that puts a capability into a page's memory and its network log for no gain when the server knows both halves. The browser gets a session id, which is worth nothing on its own. The session id is random rather than derived from the bug number: two dispatches of one report are two conversations, and a derived name would make the second reattach to the first, landing a new prompt in a session already having a different one. An open VS Code is no longer required. That was a real precondition when a dispatch was a background session handed to an editor; now it only decides whether the session ALSO appears as a tab there, so the console reports it and sends anyway. 9 tests: the receipt is bound to a member as well as a session, is spent once, expires, survives to the deadline, and is absent for the ordinary terminals usable name when the task has none. 50 tests pass, including the endpoint and architecture guards.

  23. · kamo-shared-library

    A dispatch names the terminal it is running in

    Send to AI is hosted in a Remote Terminal now, so the answer has to say which one — the console opens that window, and opening it is what starts the session. Both fields are nullable and read as "nothing to open": the dispatch history lists rows filed before this existed, and there is no window for those.

  24. · KlusterServices

    Host a Send-to-AI dispatch in a Remote Terminal

    composed prompt — and answers with an opaque TOKEN. A terminal that connects command typed into it. THE TOKEN IS THE WHOLE SECURITY DESIGN. `bootstrap=<command>` would have been half the code and a way to run anything as any provisioned member; a token means the only thing deciding what runs on this machine is the code here that composed it. The caller supplies a token — never a directory, never a command. The prompt lives in a FILE and is read with a double-quoted command substitution, so the file's contents become exactly one argv element with no word splitting, no globbing and no further expansion, whatever a reporter wrote in it. Every other part of the line is a fixed flag or a path this agent generated. Typed rather than exec'd, deliberately. The member sees the command in their scrollback, can interrupt it, scroll back through it and re-run it — which is the point of hosting the session in a terminal they can drive rather than a background process they cannot. `send-keys -l` sends it literally, so a command containing "Enter" or "C-c" is not read as key names, and the Enter is separate. /home/sage/Kamo/Projects rather than one checkout, because a bug report rarely names the right repository and a session rooted in one cannot investigate most of them. Everything the multi-root VS Code workspace holds sits under it, and it is an established working directory on this machine — 60 sessions already live in A reconnect does not re-run the command: `new-session -A` attaches to the shell that is already there, and typing into it would submit the prompt again to a conversation already having it. 16 tests: token **************** the prompt never being interpolated, the model and effort coming from the fixed vocabulary, an unknown model being refused rather than echoed, -l keeping tmux from reading a command as key names, and the title being set before the command runs. 149 tests.

  25. · kamo-internal

    Jobs Available on /hr, and a Careers tab for every member

    Two surfaces over one feature. /hr/careers is where HR writes the adverts and reads who applied; **************** is the internal board an employee browses and applies from. They share a vocabulary module and an API client and nothing else, because they answer different questions. THE HR SIDE Four sub-tabs, each carrying its count in its own label — Applicants (4), Available (7), On Hold (2), Archived (11). The count is IN the label rather than a badge beside it: a badge puts the number outside the word it belongs to and clips against the next tab. All four counts arrive in ONE console call, because the bar has to be drawable before any tab has been chosen; a per-tab fetch would leave three labels blank until somebody visited them. The masthead is the applicant population as one band, split into the seven pipeline states, sized in true proportion and drawn on load — the same thesis as the Training masthead, because it answers the same shape of question. Reading it takes no arithmetic: the width of the blue segment IS how much unread work is waiting. It is also the navigation; every segment deep-links into the Applicants queue filtered to that status. A posting card carries an applicant strip on the same principle. Two postings with eleven applicants each look completely different when one is eleven unread and the other is nine rejected and two at offer, and that difference is the first thing a hiring manager wants back when they return to a board a week later. The editor is a right-hand drawer at 62vw, not a modal — an advert is a document, the author refers back to the board while writing it, and a centred dialog on a 2560px monitor is a narrow column with grey either side. Three panes because the three are edited at different times: the advert is written once, the form is tuned per req, and the location is usually left alone. The description is the platform's Lexical editor with media off; that surface uploads into KB media, which is right for an article and wrong for a job advert. Location is a toggle, not a copy. "Use the company address" reads the org's own address live from Settings -> Essential Setup -> Behavior & Labeling; the six override fields stay mounted and filled even while it is on, so flipping the toggle back does not blank a form the author had already filled in. The application form is composed per posting — twelve questions, each Do not ask / Ask (optional) / Ask (required). A warehouse shift and a staff engineer do not want the same form, and forcing one on both is how an application becomes something people abandon halfway. The two school questions are NOT among the twelve: they are tied to the education requirement, so a posting that demands a degree always asks where it came from and there is no switch that can contradict the requirement three fields above it. THE MEMBER SIDE Available AND on-hold roles, as the brief asks and for the reason it asks: "we are not hiring for this right now" is information, and a role that vanishes is a role people ask about. Held roles are dimmed, labelled, and refuse the Apply button. Applying is two steps. Step one is the advert — read it, decide. Step two is the form, showing ONLY the questions this posting asks. Putting the form under the advert in one scroll makes every role look like a twenty-field commitment before the reader has decided whether they want it. A completeness bar counts only what this posting asks, so a short form never reports 30% because a long form's fields are absent. The advert renders through RichTextView, which escapes all text, emits a fixed tag set and checks every URL against a scheme allowlist. An advert can be written by anyone holding the HR right; it is not trusted markup. Submitting is two calls — the answers, then the file, because a resume attaches to an application that has to exist first. The second can fail on its own, and when it does the dialog says so precisely: the answers ARE stored, and "your application failed" would be false. WHAT MOVED OUTSIDE CAREERS WorkLocationType gains FLEXIBLE, so the posting editor's Arrangement field and the member profile's work location stay one list. Both member create forms gained it too — otherwise somebody created there could never be Flexible until edited later. The mirror test now pins four values in the enum's own order rather than three: the column is @Enumerated(ORDINAL), so that assertion is what keeps the list append-only. Two guards caught real defects on the way in. check-server-timestamps found daysSince parsing a bare "2026-08-22" with new Date(), which the engine reads as browser-LOCAL — a member west of Greenwich would have read a role posted today as posted yesterday. check-switch-aria found two Switches naming themselves through inputProps, which MUI 7 drops, leaving them with no accessible name at all. 42 unit tests over the vocabulary module, covering the parts a type cannot: that NONE never appears as an answer an applicant can give, that WITHDRAWN is never offered to HR, that a "no" and a zero count as answered while whitespace does not, and that the sub-tab ids round-trip so a bookmark cannot be re-pointed.

  26. · InitializerService

    Scan hr.careers, and drop the frozen work-location CHECK

    Two changes, both required before the careers entities can reach the database. The @EnableJpaRepositories list is an explicit array while @EntityScan is wildcard, so leaving com.kamo.z.shared.hr.careers out of it aborts the context, NO runner executes, and the run still reports BUILD SUCCESS having applied nothing. That is the failure this line exists to avoid, and it is silent. **************** gains **************** TeamMember.location is @Enumerated(ORDINAL), so Hibernate froze a CHECK (0..2) when WorkLocationType had three values. FLEXIBLE was appended for the /hr/careers Arrangement field — appending relabels no stored row, but the frozen range refuses the new one, and ddl-auto never widens an existing check. Without the drop, setting a team member's work arrangement to Flexible fails with a 23514 naming a constraint nobody has heard of.

  27. · SecurityService

    Seed the two careers rights onto HR's existing authoring group

    MANAGE_JOB_POSTINGS and VIEW_JOB_LISTINGS are inserted as NOT_SPECIFIED like every new right, so without a seed the /hr/careers tab and the member Careers tab would ship invisible to every organization on the platform — including to the one person who could grant them. Both reproduce MANAGE_TRAINING's group: whoever already authors HR content sees the feature on the deploy that introduces it. VIEW_JOB_LISTINGS seeds from the same narrow group, and that is deliberate rather than an oversight. The internal board is meant for the whole staff, but there is no "everybody" to seed from, and a deploy that granted a new right to every member of every organization would be making a decision that is not a deploy's to make. It lands with HR, who widen it through a security role. What the seed guarantees is that somebody can see the feature exists. A scheduler holding only VIEW_SCHEDULES gets neither. They could reach Attendance and never any authoring surface, and careers is authoring — seeding wider than the old gate is handing out access, not preserving it.

  28. · DocsService

    /api/docs/careers — job postings, the member board, and applying

    Two controllers behind one feature, and the split is the point. /careers/** is HR's and requires MANAGE_JOB_POSTINGS; /careers/member/** is the employee's and requires VIEW_JOB_LISTINGS. CareersAccess admits an HR right holder to the member endpoints as well — somebody who can read every application can obviously read the adverts — and never the reverse. Every mapping is exactly ONE call to guarded(), which resolves the session (401) then applies the gate (403). An endpoint that authorizes on its own is the drift this shape exists to prevent, and it is not hypothetical: DocsService's ResourceServerConfig is anyRequest().permitAll() and OTKPreAuthFilter CONTINUES the chain when no OTK is present, so a controller that forgets to check is world reachable through APIService's /api/docs/** forward. All seven of the legal module's original endpoints shipped authorizing on org membership alone. No member endpoint takes a member id. The session decides whose applications come back, which makes the surface structurally incapable of returning somebody else's submission — the same construction the member Timecard tab uses. VALIDATION LIVES IN THE SERVICE, NOT THE FORM. A posting decides which questions it asks through twelve ask* settings and the form honours them, but the form is a convenience and this endpoint is directly reachable. So every REQUIRED answer is re-checked, and — the part that matters more — answers to questions the posting did NOT ask are DISCARDED rather than stored. Without that, a crafted request could file a salary expectation against a posting whose HR team deliberately chose not to ask for one, and it would sit in the review screen looking like something the candidate volunteered. Applying is an upsert, not an insert. The unique constraint on (posting, member) means one person's interest in one job is one row, so re-applying after a withdrawal revives that row rather than stacking a second one HR would have to reconcile. It comes back SUBMITTED and unread, because the answers are new. canApply is decided ONCE, in CareersMapper, and JobApplicationService refuses on the same three rules — held, closed, already applied. A button that is present always works and one that is absent always would not; a second copy of the rules on the client is how a UI ends up offering a button whose only outcome is a 409. Two DTOs per row rather than one with fields blanked. hrNotes is on the HR shape and simply does not exist on the applicant's, so the way to leak it is to add the field, not to forget a conditional. Same for an unpublished pay range: it never reaches the member DTO at all, rather than being blanked in the UI where it would still be on the wire. The resume streams through these endpoints rather than through the imaging surface. Imaging authorizes on document access levels; an application's attachment is governed by MANAGE_JOB_POSTINGS, and routing it here keeps one rule. The file is sniffed from its first bytes, never trusted from the multipart Content-Type. Postings and applications are counted in grouped queries, not one per row. The Applicants tab is the one surface guaranteed to render everything it loads, and a per-posting count loop is exactly the shape that made /leads slow. 45 tests. Writing them found a real defect: the URL check tested for "://", so javascript:alert(1) — which carries no slashes — fell through to the "assume https" branch and was stored as https://javascript:alert(1). It now refuses anything declaring a scheme that is not http or https, matched as an anchored RFC 3986 scheme so a colon in a path or a port is not mistaken for one.

  29. · kamo-shared-library

    Job postings and applications — the schema behind /hr/careers

    Two entities, six enums and two repositories under com.kamo.z.shared.hr.careers, plus the three shared types the feature had to widen. JobPosting is three groups of columns worth telling apart: what the job IS (title, department, engagement shape, arrangement, description, location, pay), where it is in its LIFE (status, dates, headcount), and what its application FORM asks — twelve ask* columns, each an ApplicationFieldRequest. The location is deliberately duplicable and RESOLVED rather than copied. useCompanyAddress means "wherever the organization says it is" and is read at DTO time from the org's own address; copying it in on save would freeze it as it was that day, so an office move would leave every open posting pointing at the old building. Nothing would fail — the adverts would simply be wrong. The six override columns hold the alternative and are all optional, so a posting may name a city and nothing else. ApplicationFieldRequest is ONE enum for twelve questions rather than a boolean per question plus a separate resume enum. A boolean cannot say "ask, but do not insist", and the moment HR wants that for one field the boolean has to be widened anyway. The education questions are NOT among them: they are tied to requiredEducation, so a posting that demands a degree always asks where it came from and there is no switch that can contradict the requirement three fields above. JobApplication is one row per (posting, member), enforced by a unique constraint. Withdrawing sets WITHDRAWN and re-applying revives the same row rather than adding a second — so nobody can flood a posting, HR's applicant count is a count of people, and one person's interest in one job stays in one place. The applicant's name, email and phone are SNAPSHOTTED at submit: they are also live on Member, and that is the problem. A hiring record has to say who applied and how to reach them on the day they applied, and a member who later changes their preferred name must not silently rewrite an application HR is part-way through reading. The three widened types: - WorkLocationType gains FLEXIBLE. The member profile's work-location field and a posting's Arrangement are the same question asked at two moments, so they share one enum — a private copy on either side drifts the day the other gains a value. FLEXIBLE is distinct from HYBRID: hybrid is an employer-set split, flexible says there is no such split to state. Appended, so no stored ordinal moves — but TeamMember.location is @Enumerated(ORDINAL) and Hibernate froze a CHECK (0..2) when the enum had three values, which ddl-auto will not widen. The drop is in KamoInitializer's **************** - RoleRightType gains MANAGE_JOB_POSTINGS (302) and VIEW_JOB_LISTINGS (303), both standalone roots under HRS and deliberately NOT a VIEW/MANAGE pair. The first is a tab right in the shape of 182-186; the second is held by ordinary staff so they can read the internal board and apply. Pairing them would either hand every employee who can see the board the ability to rewrite the adverts on it, or make withdrawing HR's own grant close the board to everybody. RoleRightHierarchyTest's pinned counts move in this commit: values 287 -> 289, standalone 38 -> 40, roots 76 -> 78; children is unchanged. - ImageAssocType gains JOB_APPLICATION (id 19, ordinal 17) for the resume, scoped to the APPLICATION rather than the applicant — HR reading one application must see the file sent with THAT application, not the most recent thing the member uploaded anywhere. It gets its own StorageDomain rather than being folded into HR_RESOURCES, which is the tempting place and the wrong one: HR resources are a library the org curates and prunes, these are documents third parties send in and the line only grows while a board is open. Two coverage guards caught that last change and are the reason it is complete: StorageDomainCoverageTest refused an association no domain billed, and ClinicalDocumentReuseTest asserted PATIENT_CHART "must be last". The second was asserting a proxy — what actually has to hold is that its ordinal is still 16, so a value inserted BEFORE it is caught while a value appended after it is allowed, which is the one change the enum is explicitly designed for.

  30. · kamo-internal

    A list of your live shells, in the panel and beside the window

    Terminals joins the Interaction Center to the right of Sales Scripts, gated on a provisioned dev machine — the same gate as the Linux Desktop nav option, read off the same resolver, so the tab cannot answer differently from the icon inches away from it. Not rendered at all without one, as Sales Scripts is: a member should not learn that other people have shells on a build machine from a tab they can never open. A maximized terminal now gets the same left navigator the chat windows get, and it shows THAT SAME LIST — one hook, one pane component, rendered in both places, for the reason toolSections is shared by the nav and the launchpad: two copies would disagree the moment one of them ended a session. `chatSwitcher: boolean` becomes `navigator: 'chat' | 'terminal'`. One field naming which navigator, rather than a flag per kind, so a tool asking for both is not expressible and nobody has to remember where the precedence lives. Rows open, rename and end. Ending is two steps, because it takes the shell and whatever is running in it with no undo, and the confirm is the row itself rather than a dialog so it cannot be dismissed onto the wrong terminal. Renaming is inline; the stored title comes back from the server and is rendered from there, because the agent trims, strips control characters and truncates. Reopening a terminal RAISES its window instead of stacking a second one onto the same shell: the id travels as `sessionGuid`, which the tool-windows store already dedupes on for any non-chat tool. That is why the prop is named that. A host restart needs no special handling and gets none — the list is the tmux server's sessions, so a reboot empties it by itself. What the list does distinguish is "you have no terminals" from "nobody could tell you", because those look identical in an empty list and mean opposite things: one invites you to open one, the other says not to bother yet. The window is the chat footprint (460x690) as asked — it sits in the same dock row, and a window a different size from its neighbours packs the row badly. 415 test files / 5220 tests, tsc and the production build all clean; the three API routes appear in the build manifest. Dictionary pushed first (f747461f) and verified against a fresh clone the way CI does it.

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