Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-marketing

    Enforce a CSP, COOP and Trusted Types

    Adds the three headers the Trust and Safety audit asked for, plus Referrer-Policy and Permissions-Policy. Every origin in the policy was taken from what the site actually loads rather than guessed — the chat widget bundle on theme.kamocrm.com (which opens a WebSocket to media. and calls api. and capcha.), the analytics script on analytics., the hero's HLS ladder, and the ***'s challenge endpoint. Audited the built page against the finished policy resource by resource: zero blocked. ON script-src 'unsafe-inline' — this is a real compromise and worth stating rather than burying. The strong form of a CSP is a per-response nonce with 'strict-dynamic', and it is not available here: a nonce means reading headers() during render, which opts the route out of static generation. This app has ~200 prerendered routes and their being static is load-bearing — a dynamic route with no loading boundary prefetches nothing, so every link click starts cold. There is no hash-based escape either, because the inline scripts are Next's own RSC flight data, whose contents differ per page and per build. So: this CSP does NOT stop an injected inline script. What it does do is confine every external script, fetch, frame, image and media load to known origins, forbid plugins, pin the base URI so injected markup cannot re-root relative URLs, and stop form posts to third parties. TRUSTED TYPES took three attempts to make safe, and the failures are the interesting part: 1. A plain inline <script> first in the layout's <head> is not first. React hoists Next's `<script async src>` tags above anything the layout renders, so the policy landed after twelve of them — and async scripts execute the moment they finish downloading. Turbopack's runtime is among them and does `createElement("script")` then sets `.src`, the exact sink that throws without a policy. 2. `next/script` with strategy="beforeInteractive" did not move it either; still twelve scripts ahead. 3. What makes it safe is that no sink can fire that early. All 78 flight-data pushes are in the body — first at offset 193,072, with </head> at 11,940 — so React cannot begin hydrating until well after the policy is installed, and there is no sink-touching code in <head> before it. Verified on the built HTML rather than assumed. Two things would have broken without being grepped for. The *** widget is a Svelte build and creates a policy named `svelte-trusted-html`; a policy name is a runtime string, so nothing about it surfaces in types, lint or the build, and omitting it from the allow-list would have thrown and taken the contact and demo forms down. And hls.js and the *** both build Web Workers from createObjectURL, so the policy has to pass `blob:` before its origin test — a blob: URL has no useful .origin and would otherwise have failed it, taking the hero reel with it. The policy's createScriptURL is checked against kamocrm.com, which is strictly more permissive than script-src, so it can never refuse something the CSP allows. createHTML passes through and does NOT sanitise: a real sanitiser would have to ship on the critical path and could silently mangle the JSON-LD blocks and the widget's markup. That half is a chokepoint and an audit hook, not a filter — and it is where to harden next. Cross-Origin-Resource-Policy was written and then removed. Applied across `/:path*` it covers the Open Graph images, which exist specifically to be embedded from other origins. frame-ancestors is 'self' to match the X-Frame-Options: SAMEORIGIN already being served, rather than tightening to 'none' and breaking an embed nobody remembered. Verified against the real standalone server, not just the config: headers present on both a prerendered route and a *** route, and the long-lived asset Cache-Control still intact.

  2. · kamo-internal

    Build out Tracking Codes in place of the referral placeholder

    /marketing/discounts was a ComingSoon stub; it is now /marketing/tracking, a working workspace, with the old path kept as a client redirect so existing bookmarks still land. The tab reads "Tracking Codes". A member mints a code and the page shows them the link it produces, built from the host actually serving the page — internal.acme.com yields **************** — so it is correct for every white-label domain without an apex being hardcoded anywhere. The grid carries the six totals per code, and a row opens a drawer with the conversion funnel, the last thirty days, top locations, devices and referrers, and the visitors themselves; a visitor opens their own panel with origin, network, client, UTM attribution and a timeline of every page they opened. Country flags use react-country-flag, as elsewhere. Scope follows the rights: "My team" appears for either cross-member right, "All members" only for VIEW_ALL_MEMBER_TRACKING, and the selector follows the scope the SERVER says it applied rather than the one requested, so a downgrade cannot leave the member reading their own rows under an "All members" label. Chrome paints from --color-primary and --color-secondary throughout, since every org is white-label and MUI's default contained button would render the same blue for all of them. Two things the grid needed that are not obvious. Row clicks are filtered on an explicit marker attribute rather than stopPropagation, because AG Grid listens natively on the row container while React delegates to the root — the React handler runs second, so the copy button and the row menu would both have opened the drawer. And the search is server-side only: a second client-side quick filter could only subtract from a correct result set, hiding rows that matched on member name in a scope with no member column. VIEW_REFERRAL_CODES and MANAGE_REFERRAL_CODES leave the unenforced list — they gate real things now — via a regenerated audit rather than a hand edit. The TS mirrors gain the two new rights and the MARKETING ServiceType they point at, which had been missing.

  3. · kamo-register

    Carry the marketing referral into the signup

    The marketing site tags a referred visitor with kmrt_vid and kmrt_src on the shared apex, so register.<apex> can read them; handleFinish sends both in the request body, falling back to ?mrt= for the code when the visitor arrives with the link still in the address bar. Body, not headers: the /api proxy strips Cookie and the ingress rewrites X-Forwarded-Host, which is also why the cookies are not httpOnly. Absent values are omitted rather than sent as empty strings, since most signups have neither. The cookie reader the logout page had inline is now app/lib/cookies.ts and shared by both callers, rather than a second parser being written beside it.

  4. · kamo-marketing

    Honour a partner link and report what it brought

    A visitor who follows https://www.<apex>?mrt=<code> gets two first-party cookies from the edge middleware — an opaque GUID that lasts two years and the code itself for ninety days — and every page they open afterwards is reported once, from idle, by sendBeacon. The domain is derived from the forwarded headers, not from nextUrl. Next builds the middleware's URL from its own listen address, so nextUrl.hostname is the container's, and the Domain attribute silently never appeared: a host-only cookie pinned to www that register.<apex> cannot read, which loses the referral with nothing failing anywhere. cookie-consent deletes through the same helper, so the two cannot drift apart again. A visitor who never followed a partner link is never given a cookie and never beacons at all. That, rather than a banner toggle, is what keeps this proportionate: the record behind a partner's totals is what that partner is paid against, and a consent gate would not make the numbers smaller, it would make them wrong — the visits still happen and the signups still convert, they just stop being attributable. Both cookies are disclosed under Essential on the Cookie Policy with a control that clears them. Our own product analytics stays gated. /api/mrt is a trust boundary, not a pipe: it attaches this site's API key, so it validates the GUID, the code and the reported URL, rebuilds the payload from values it owns, refuses a cross-site caller, and forwards only the ingress-set peer IP — the inbound X-Forwarded-For chain is client-controlled at its left end and would otherwise let a caller choose the IP the commission report records. It answers 204 before forwarding, and warns at most once a minute when the upstream is unreachable, because the previous shape treated a 404 exactly like success. Static rendering is untouched: no cookies() or headers() in any page, no Script tag, no framer-motion on the beacon. check-mrt-cookie.ts pins the invariants that have no other guard — including that the beacon stays ungated and that nothing is collected from an unreferred visitor.

  5. · SecurityService

    Publish signup and org-created for tracked visitors

    The register site now forwards the marketing visitor GUID and tracking code in the request body — not a header, because the proxy strips Cookie and the ingress rewrites X-Forwarded-Host — and register() passes them to a signup signal when they are present. Most signups carry neither and publish nothing. MarketingSignalPublisher serializes with a LOCAL ObjectMapper, deliberately not the shared bean. That bean also writes the Redis *** session map and 65 call sites read ids back out of it as Number; giving it a global Long->String rule turns org resolution into a ClassCastException that the helpers swallow, and a null org is a 401 on every authenticated request across four services. The publisher is best-effort, holds an optional NATS connection, and never throws. Both signals are registered afterCommit with the payload snapshotted inside the transaction, so the consumer cannot look up a user or organization row that has not been committed yet. RoleRightsSyncSeedTest gains a negative case: neither of the two new tracking rights may ever enter NEW_RIGHT_SEEDS, since seeding them would grant every existing referral-code holder visibility into colleagues' visitor-level analytics on the deploy boot. Their ids are pinned at 200 and 201.

  6. · BillingService

    Publish the paid conversion for tracked organizations

    Both paths that stamp BillingSourceType.PAID now publish marketing.visitor.paid, because hooking only one silently misses half the traffic — AccountSubscriptionService handles the console's own subscribe, ConsumerCheckoutService the Stripe checkout. Three things this gets right on purpose. It publishes on the TRANSITION, reading the previous source before the write and firing only when it was not already PAID, so a plan change or a re-POSTed "Change Plan" cannot inflate the counter. It snapshots into an immutable record inside the transaction, because account and targetOrganization are both lazy and reading them from afterCommit throws. And the org it reports is the TARGET organization, never the payer's — a parent account routinely pays for a subscription targeting a child org, and the funnel counts the org that converted, not the one holding the card. Registered through **************** rather than published inline, which also gets recordPaidFromCheckout right: its @Transactional joins the Stripe webhook's outer transaction, so only an afterCommit hook fires at the real commit boundary.

  7. · APIService

    Forward the tracking beacon to MediaService

    Clones forwardPublicWebinar rather than the generic forward(): that method prints every URI, header map and body length to stdout with no level, and a visitor beacon carries an IP and a referer. The API key here is a rate-limit and abuse control, not a tenancy boundary. One marketing pod serves every www.* host with one public key, so the key's org is always the platform org — the tracking code's own organizationId is what decides tenancy, downstream in MediaService. That reasoning is in a comment at the call site so it does not get "fixed" into a broken org check later.

  8. · MediaService

    Serve the tracking-codes tab and ingest the visitor beacon

    MediaService already backs the other marketing tabs, so it takes both halves of tracking codes: the authenticated CRUD and stats surface at /api/media/marketing/tracking, and the public ingest worker behind APIService's edge. The ingest handler does nothing but validate and enqueue. Everything real — the code lookup, the geo lookup, the user-agent parse, the four writes — happens on a bounded executor off the request thread, and the response is a 202 with an empty body regardless of outcome, because a marketing page must never wait on analytics and a public caller must never learn what went wrong. The rejection policy is AbortPolicy, not CallerRunsPolicy: caller-runs would push the database work back onto the HTTP thread, which is the exact thing this shape exists to prevent. A full queue drops the hit and counts it. Every client-supplied string is clipped to its column width before it is persisted. Hibernate does not enforce @Column(length) at runtime, so on a public endpoint an over-length Referer or Accept-Language is SQLSTATE 22001 and takes the whole beacon down with it — trivially triggerable by anyone. A visit is new when the link row was last seen more than 30 minutes ago, matching the analytics install's own window so the two never disagree about "visits". Bots are parsed, dropped, and counted against nothing. Scope is resolved server-side and re-validated: "managed" needs either cross-member right and always includes the caller, "all" needs VIEW_ALL_MEMBER_TRACKING and reads by organization so no IN clause can ever be handed an empty collection. An unauthorized scope downgrades to "own" rather than erroring, and the response echoes the scope actually applied so the client can move its selector instead of lying about what the member is looking at. Responses are records with String ids and numeric counters. MediaService has no wire-level Long->String rule, so a raw Long in a Map would ship a JS-lossy number with nothing to catch it; TrackingCodeDtoWireTest pins both halves of that. The conversion consumer's durable filter is marketing.visitor.* and is fixed forever — a durable keeps the filter it was created with, and widening it later yields a permanent SUB-90011 on a consumer that still looks healthy. It bumps a counter only when the conditional stamp actually affected a row. A nightly reconcile recomputes all six counters from rows this service owns, so a dropped increment heals itself.

  9. · kamo-shared-library

    Model tracking codes, their visitors and the visits they bring

    A member mints a code, shares **************** and the six totals on the Tracking Codes tab answer what that link actually did: unique visitors, visits, page requests, signups, organizations created and paid accounts. Four tables carry it. MARKETING_TRACKING_CODES stores the code string itself rather than recomputing it from memberId and iteration, so the public ingest path is one unique-index probe with no parsing and a malformed ?mrt= is a miss instead of a NumberFormatException. MARKETING_VISITORS is the identity spine, one row per cookie GUID, and its attributed code is written once and never moved: with first-touch attribution the three conversion counters cannot double-count a visitor who later arrives on a second code. MARKETING_VISITOR_CODES is the visitor x code link and the authority for the traffic counters, and MARKETING_VISIT_EVENTS is the append-only timeline the visitor drawer reads. The counters are maintained, not derived, because the grid renders six of them per row and six COUNT(DISTINCT) aggregates over an append-only table on a row store is a scan per page load. What makes that safe is that every bump is an atomic SET x = x + 1 rather than a read-modify-write, and every conversion stamp is a conditional UPDATE gated on IS NULL so a redelivered NATS message updates zero rows — the rowcount is the dedupe. Both "first time seen" tests are native INSERT ... ON CONFLICT DO NOTHING for the same reason. The obvious alternative, catching the unique violation and re-reading, cannot work here: on YugabyteDB the violation aborts the whole transaction (25P02) and Spring marks it rollback-only, so a lost race would drop the entire beacon — the event row, the visitor row and every counter with it. Rights: 154/155 keep their constant names and are only relabelled to "View" and "Manage Tracking Codes", because rights travel by name in the session token and a rename would strip them from every live session with no compile error. Ids 200 and 201 are new and gate cross-member visibility. Neither is seeded into NEW_RIGHT_SEEDS: before this feature nobody could see another member's visitor analytics at all, so seeding from VIEW_REFERRAL_CODES would hand every existing holder a capability they never had.

  10. · KlusterServices

    Provision MARKETING_EVENTS for visitor conversion signals

    Marketing tracking codes credit a partner for the signups, organizations and paid accounts their link produced, and those three moments happen in services that do not own the tracking tables: SecurityService publishes marketing.visitor.signup and marketing.visitor.org-created, BillingService publishes marketing.visitor.paid, and MediaService consumes all three. Nothing creates this stream application-side — the producers only publish — so it has to exist here before they deploy, or the first conversions are published into nothing and the counters silently stay at zero. R1 like every other stream on this single-node server.

  11. · kamo-internal

    Shorten the empty-description hint

    The Description column shows {No Description} in place of "No description provided..". The string was hardcoded English, so it now comes from the dictionary — with the braces applied in the component, since a message carrying them would be parsed as an ICU placeholder.

  12. · kamo-internal

    Reorder and retitle the document tabs

    The strip now reads Originals | Modify (n) | Modified | Binders | Common | Uploads, and Common trades the gear icon for a library one now that it no longer says "System". Each tab pins an explicit `value` so the visual order is free to change without renumbering the activeTab checks that the rest of the file is built on. Shared With Me, History, Hidden Docs and Trash drop their labels for tooltips — History moves out of the main strip to join them, which also fixes its fetch effect: it was gated on tab 5 (Uploads) while the panel renders on 6, so the history lists never loaded.

  13. · kamo-internal

    Rebuild the knowledge base as a console

    The four knowledge-base screens were the last in the app still wearing the old look, and three of them stated things that were not true. /kb showed "Pinned Articles" from an endpoint that sent pin rows with the article stripped out by @JsonIgnore — the section has never appeared for anybody. Beneath it, "Recent Articles" was the first ten nodes of the article tree, in tree order, each stamped PUBLISHED with a blank date: neither recent nor necessarily published. The tree beside them collapsed itself on every navigation, because each branch held its own useState. One idea carries the rebuild: THE RAIL IS THE FILTER. A tile's count and the list it opens both come from matchesFilter in the model, so a tile reading 4 opens exactly those 4. There is no second row of chips restating the same predicates in different words, waiting to disagree. Around it: search that matches title, address and body and marks which one it hit (the old box debounced 500ms to ask a server that searched contentPlainText only, so typing an article's exact title returned nothing), a tree that remembers what you opened and can be resized, faces on every card from the member directory, and a Needs Attention panel — reviews that have waited, drafts gone quiet, published articles nobody has touched in a quarter. /kb/resources was a modal dialog rendered flat onto a route, with open={true} and an onClose that navigated away. It is a library now: a rail that weighs what the organization is storing and doubles as the type filter, tiles with real faces for video and files rather than a grid of identical grey boxes, drop-anywhere upload with real per-file progress (fetch cannot report progress, so a 40MB video looked exactly like a 4KB icon), and renaming in place instead of a modal launched from a footer. The editor's insert-media picker is now the same tiles, the same detail pane and the same useKbMedia hook in a dialog — one kit, two surfaces, so they cannot drift. /kb/reviews was a list of titles. A reviewer could see that four things were waiting and nothing else — not who wrote them, not how long they had sat there, not what they said — and deciding meant opening each article, scrolling to a panel at the bottom, and coming back. Worst of all a failed fetch rendered as "No articles pending review": the queue reported itself clear on a day it may not have been. Now the queue carries each article's age, submitter and size; the article sits beside it; and the verdict buttons are next to what they apply to. /kb/new was a form that told an author nothing. The slug field said "Auto-generated from title" and then let you save a slug the server rejects with a 400 after the article is written. The parent dropdown indented options with two spaces, which HTML collapses, so every option looked top-level. And all three visibility boxes could be ticked with AI training left off, which silently means nobody sees it anywhere. The composer answers those three questions before the work rather than after the rejection, and offers Save draft beside Create and submit. Fixed along the way, all of them visible: - KbStatusBadge shipped four hardcoded English words and four hardcoded hex pairs — a Spanish console said "Draft" in a grey no white-label palette could move. - KbPinButton captured isPinned once, so a page that learns its pin state after the first render (every page here) showed an outline pin over a pinned article forever; a failed request left the pin looking set. - The article reader read article.pinned, a field KbArticle does not have. - mediaId was declared number across the four editor node types and extractMediaIdsFromJson, while KB_MEDIA.UID is a UUID and the values have always been strings. It survived only because nothing ever did arithmetic on one; a single Number(mediaId) would have been a silent NaN. - KbMediaResource.uid had the same lie. - Media requests raced: a slow early search response could land after a fast later one and repaint the grid for a query nobody was looking at. The pure rules live in app/lib/kbConsole.ts and app/lib/kbMedia.ts with 52 tests, because vitest here collects .test.ts only — a rule written inside a .tsx is a rule with no test. What they encode throughout: an unknown is never a zero. A missing date sorts last rather than becoming the epoch, an unrecognised status paints neutral rather than green, an empty queue has no oldest item rather than a zero-day one, and a resource whose usage count never arrived warns instead of promising it is unused.

  14. · SecurityService

    Enforce is_fake, and close the holes that let an unattended signup in

    Two halves of one incident. An account registered, verified an address at a disposable provider, took an auto-login session and created five organizations in three minutes and forty-two seconds — four of them byte-identical "Acme Corp" rows differing only in their domain. It produced ZERO rows in system_access_logs, so there is no IP and no user-agent on record for any of it. ── Enforcing the flag **************** - UserAuthenticationService: `u.is_fake = FALSE` goes into ACCOUNT_QUERY_PREFIX, not into a caller. All three login identifiers — username, account email, and the primary-mailbox re-lookup keyed by id — reuse that one projection, so one clause closes all three and a fourth identifier added later inherits it. A flagged account reads as non-existent and gets the generic "user does not exist or password is incorrect"; a message naming the flag would tell an abuser exactly what to change. - KSessionService.createSession: refuses outright. This is the narrowest waist in the system for "may this account act" — every *** is minted here, so one gate covers password login, enter-as, desktop SSO, device auth AND the register funnel's post-verification auto-login, which is how the account in question got a session without ever calling /login. Throws rather than returning null, because thirty callers assume a non-null id and would NPE somewhere else entirely; login and the auto-session path catch it and shape a clean answer. - FakeAccountGuard: two tiers on purpose. The single-account check is UNCACHED (it backs the hard gates, where a stale answer means a flagged account still gets in); the id sets are cached for 60s (they back read filtering, where the worst case is a synthetic org lingering in a listing for under a minute). Fails open — hiding a handful of rows is not worth 500ing an org listing — and does NOT cache a failure, so one blip does not become a minute of the flag silently not working. - OrgObjectStorageSweep: stops snapshotting synthetic orgs. This is where the money was: snapshots are written per org per day forever, and five abandoned orgs had already become ~9% of that table. An account that cannot log in does nothing about work the platform keeps doing on its behalf. - FakeAccountController: flag, unflag, list — behind MANAGE_ORGANIZATIONS rather than a new platform right, because kamo-internal's platformRightCoverage test asserts the right set against the console's rendered surfaces. Revokes live sessions on flagging, since the gates are on MINTING a session, not on using one; refuses to flag the System User, which would make the platform unable to enter any child org. ── The holes **************** - /register never read `***Token`. The register UI has always solved an Capcha challenge and posted the result; grep for the field across all Java returned nothing. The widget was in the browser and the endpoint was wide open. Now verified, and FAIL CLOSED — asymmetric with login on purpose: login can only verify a payload when one is present, because the mobile client sends none and hard-requiring it there would lock real users out. Registration has exactly one client and it already sends the token. - CapchaVerificationService returned TRUE when `capcha.service-url` was unset, so a config omission was indistinguishable from a solved challenge. Now rejects, behind `capcha.allow-unconfigured` (default false) for local runs. No production change — the property is set in kamowssecurity-config — but the latent bypass is gone. - POST /org had no rate limit of any kind. Now capped per ACCOUNT (not per IP — an IP key would punish everyone behind one corporate NAT). Deliberately generous: a real customer created three orgs in five and a half minutes, two with the same name and alias, while working out the wizard. A tight limit would have blocked them. Counts attempts, not successes, and fails open on a Redis outage. - The org alias had NO uniqueness check on this path — only the domain did, which is why four live orgs share "acme-corp" and why the creator varied only the domain each time: it was the one field the server would reject. Now a 409, scoped to the security provider because `(security_provider_id, alias)` is the pair login resolves a web-alias host on. - REGISTRATION / REGISTRATION_REJECTED / EMAIL_VERIFIED / ORG_CREATED are now written with IP and user-agent. Every detection rule keys off login events, so the whole register funnel sat outside the detection engine's field of view. Deliberately NOT done, having checked: login is not made ***-strict (breaks the mobile app, needs a coordinated release); disposable-email domains are not blocked (a real customer signs in from proton.me, and a naive "not a mainstream provider" rule blocks real people); no content-scoring auto-block (the strongest-looking behavioural signal — duplicate org names created minutes apart — matches a real customer exactly). Schema prerequisite already satisfied: KamoInitializer applied users.is_fake before the shared-lib push, verified as `boolean NOT NULL DEFAULT false` with its partial index, and re-ran idempotently without clearing the flag.

  15. · KBService

    Serve what the knowledge console reads, and fix what it never could

    Four endpoints the rebuilt knowledge base needs, and three facts the API was unable to state. GET /api/kb/pins returned a Page of pin ROWS, and KbArticlePin.article is @JsonIgnore — so the response carried a pin id, a member id and a date, and nothing about which article had been pinned. The home page has asked for that list since it shipped and rendered nothing, because a Page envelope has no .length and the guard in front of the section was a length check. Pinning has never worked end to end. It now returns the articles themselves, resolved inside a transaction rather than leaning on open-in-view. GET /api/kb/articles/overview is new: status counts, every article's real dates, authorship, word count and excerpt, plus the caller's pinned guids, in one read. The home page used to fake "recent" by taking the first ten nodes of the tree and stamping them PUBLISHED with a blank date. GET /api/kb/reviews/queue is new: what is waiting and what was recently settled, both halves in one request. A reviewer looking at an empty queue asks the same question either way — is nothing waiting, or did I just clear it. Its decided read is a LIST with a limit, not a Page: inside the read-only transaction a Page's discarded COUNT is never statement #1, so Yugabyte cannot restart it and a concurrent write turns the queue into a 40001. GET /api/kb/media now sends each row's articleCount and, for the types a grid draws, its presigned URL. The count was only ever populated by the single-resource endpoint, so the library's "used in N articles" warning before a delete could never fire — a diagram embedded in nine articles deleted as quietly as an orphan. The URLs were one request per thumbnail after the list had already landed. POST /api/kb/media/urls signs a batch for callers that hold rows already. Every int64 member id leaves as a STRING. They are 19-digit unique_rowid() values and a JSON number is an IEEE double in the browser — the submitter id on the review queue arrived pre-rounded, which is why it could never be matched against the member directory and a raw id sat where a name belongs. No shared-library change, so no KamoInitializer run and no fleet redeploy.

  16. · InitializerService

    Add users.is_fake, in the only statement order a populated table allows

    Mirrors the new `User.isFake` field. Four statements, and the order is the whole migration: YugabyteDB rejects `ADD COLUMN … NOT NULL` on a populated table — rejecting the entire statement, not just the offending clause — so the column arrives nullable, is backfilled, gains its DEFAULT, and only then is tightened. Collapsing these into one ALTER produces a migration that logs a warning and leaves the column absent, which is success-shaped output for a total no-op. `@Order(0)` per the column-add convention. The moment the shared-lib entity maps `is_fake`, every later runner that loads a `User` through JPA emits it in its SELECT, so a late-ordered migration would never be reached — an earlier runner dies on `column u1_0.is_fake does not exist` and takes the run down first. The index is partial (`WHERE is_fake = TRUE`). The guard's hot query is "which ids are flagged", and on a table where effectively every row is FALSE a full index on the column is dead weight while a partial one holds a handful of entries. The backfill's `WHERE is_fake IS NULL` is load-bearing, not decoration: KamoInitializer re-runs in full every time, and a bare `UPDATE users SET is_fake = FALSE` would silently un-flag every account an operator had deliberately hidden, on every run. A test asserts that clause is still there. Applied to production 2026-08-19; all five statements logged applied, BUILD SUCCESS. Column verified as `boolean NOT NULL DEFAULT false` with the partial index present.

  17. · kamo-shared-library

    Add is_fake, the flag that makes an account invisible instead of deleted

    A synthetic account — a test artifact, a seeded fixture, or a signup judged to be automated — now carries `users.is_fake`, and the platform stops acknowledging it. Deleting such an account is not a real option: a user row is the root of seven satellite tables, every Member it owns and every Organization it created, which in turn own domains, features, email templates, roles and storage snapshots. Deleting means cascading through all of that or leaving orphans, and it destroys the evidence of whatever the account did. A boolean is reversible and costs one column. The flag lives on the USER, not on the Member or the Organization, because ownership is the only edge that reliably reaches the derived data: `orgs.user_id_owner` and `members.user_id` both point back here, so one flag answers "is this row's originator synthetic" for all three spines. Flagging the org instead would leave the account able to sign in and create more. What the flag reaches, and why each one is here rather than in the caller: - `UserRepository.findSession` and the auth projection: the flagged account resolves to zero rows, so it reads as non-existent rather than as blocked. - `MemberRepository` / `TeamMemberRepo`: excluded from org member listings and from both directory searches, alongside the System User that was already filtered there. - **************** the enumeration choke point nine call sites share. `getOrganizationById` is deliberately NOT filtered — it backs writes and it is how the admin surface reaches a flagged org to unflag it. - **************** a flagged org stops resolving by host, so it cannot keep accepting real signups under its own register subdomain. - **************** filtered in the query rather than in the watcher, because the sweep takes a fixed batch every minute and a domain nobody owns would otherwise hold one of those slots forever, probing someone else's DNS. Every one of these uses an explicit aliased LEFT JOIN. A bare `o.owner.isFake` path in a WHERE clause makes Hibernate add a second, implicit INNER join, which would silently drop every row whose owner is null — and for `findByDomain` that is login resolution. The finders above are additive; the identity finders on UserRepository are left unfiltered on purpose. Most of them are load-bearing for uniqueness pre-checks as well as for lookups, and `USERS.EMAIL`/`ALIAS` are unique per security-provider — so a registration duplicate-check that could not see a flagged row would sail past its own guard and die on a constraint violation instead. Also adds REGISTRATION, REGISTRATION_REJECTED, EMAIL_VERIFIED and ORG_CREATED to AccessLogEventType. `system_access_logs` only ever held login, failed login and logout, so an account that registered, verified, took an auto-login session and created five organizations produced zero rows — no event, no IP, no user-agent — and every detection rule keys off login events, leaving the whole register funnel unobservable. `users.is_fake` already exists in production: KamoInitializer's UserIsFakeMigration ran before this push, so no service can build against an unmigrated schema.

  18. · kamo-internal

    Rebuild /hr/attendance as a payroll console

    Eight flat tabs over eight panels that each drew their own heading, table, skeleton, error line and Snackbar — and had drifted. The overhaul is three things at the page level and a rebuild of each panel on top of them. **One summary read** backs a new command bar and every rail badge. It answers the question the screen could not: whether anything stands between here and payroll. That used to take three clicks — an admin opened the roster with no way to know that twelve blocking exceptions would refuse every approval they were about to make on the next tab. Blocking and awaiting chips deep-link to the panel that owns them. Polling is gated on page visibility, so a tab left open overnight stops asking. **One toast host**, hoisted above the panel switch. A message raised by an action that navigates — "clear the blocking exception first", which sends the reader to another sub-tab — used to unmount with its own panel before anyone read it. **One tone map** (attendanceConsole.ts, unit-tested) replaces three that already disagreed, and carries the rule this module turns on: an unknown is never a zero. A withheld approval count, an absent net-minutes figure and a period nobody has generated all reach the eye as "we were not told", because rendering any of them as 0 tells an HR admin a queue they cannot see is clear. `railBadgeFor` therefore never emits a zero badge, and `consoleVerdict` is `unknown` before the first read rather than `clear`. Two gaps closed that were not cosmetic: - **A person is a person.** Five panels printed a raw 19-digit `unique_rowid()` on rows deciding whether to pay somebody. `PersonCell` shows the face and name, with the id on the tooltip; ids stay STRINGS end to end and nothing parses one. `useMemberDisplayNames` now caches the whole directory row rather than the name alone, so the same walk feeds avatars and job titles instead of needing a second one. - **A drill-in that never existed.** The member Timecard tab under /settings is self-only by construction — its endpoints take no member id — so the only route from "this person's hours are wrong" to "here is the punch" was the exception queue, which lists the day and not the shift around it. `MemberTimecardDrawer` opens the existing period editor on another member; those endpoints take a `subjectMemberId` and SecurityService attributes the adjustment to the HR actor, which is what makes the reuse correct. Per panel: the roster gains search, sort, a card and a table view, a current-period range and CSV (every value the server's own string — a file payroll reconciles against must not carry a number this app invented). Approvals becomes keyboard triage with clear cards separated from blocked ones and an "approve everything ready" that still walks the state machine one card at a time and names every refusal. Exceptions gains a type histogram that doubles as a filter and, more importantly, a **Fix it** action: the screen shipped offering only Re-detect and Waive, neither of which puts a missing punch back, so the path of least resistance from "a day is missing its clock-out" was to waive it — precisely the decision the queue exists to make somebody think about. Pay periods gains a timeline, because a gap or an overlap in a calendar is visible on a track and invisible in a list. Export computes the variance against the prior period instead of advising the operator to remember to compare. Close becomes a numbered stepper, since the order is real: recomputing after approving changes figures already agreed to. One `ReasonDialog` replaces six hand-rolled ones that had drifted — some trimmed after checking, so three spaces enabled the button and submitted an empty reason the server took as present. Under Anderson v. Mt. Clemens Pottery that reason is the record, so the check is on the trimmed value now. `TIMECARD_SUB_TAB_IDS` is untouched: it stays the append-only URL contract and `ATTENDANCE_RAIL` reorders only the pills, so every bookmark still lands. A test asserts the rail covers every id exactly once.

  19. · kamo-internal

    Rebuild the network page as an operations console

    The page was the last screen still wearing the old look — a flat gradient bar, white cards, a centred spinner, alert() for every failure — while the rest of the console had moved to the instrument-panel language. It also threw away data the backend was already sending: each organization's own brand colour, its full domain list, its device policy. One idea carries the rebuild: the numbers are the filters. The old page had a row of counts and, separately, a row of filter chips restating the same predicates in different words — two controls to keep in sync and two chances to disagree about what "owned" means. A stat tile is now its own predicate, and both the count and the selection come from matchesFilter in the model. What the page gained: search across all six fields with the match marked (a secondary domain used to return nothing even while the card showed "+2 more" beneath it), / to focus it, four sorts, grid and dense-list views, pinning, and a detail drawer addressable as ?org=<id> — which is what let the cards get quieter, since the row no longer tries to show short title, alias, year, config, domain and display name at once in 0.7rem grey. View, sort, pins and recents persist in the member's ui-preferences, so the arrangement follows them between machines. Unsubscribe is wired to the endpoint that now exists, behind a typed confirmation: every other control here is reversible by the person using it and that one is not. The 1341-line file is now arrangement only. What an organization is, and every question the screen asks about one, lives in networkModel.ts under unit test; what the server is asked lives in hooks/. The three views read the same derivations, so they cannot drift into disagreeing about which organizations are reachable. Motion is real but quiet: staggered entry, layout reflow on filter and sort, one slow drift in the masthead, and the console's existing kmail skeleton instead of a spinner. All of it gated on prefers-reduced-motion. Three bugs found while writing it, each fixed here: comparing entering?.orgId to detailOrg?.id is undefined === undefined when both are absent, and then reads .mode off nothing; window.open with noopener returns null, so a pre-claimed tab could never be navigated (the opener reference is severed instead, which is the same protection); and a next-intl callback in the fetch effect's dependency chain could turn a re-render into a refetch loop, so the message helpers are read through a ref.

  20. · SecurityService

    Let a member give up their own membership

    The network page has offered "Unsubscribe" since it was written. It opened a confirmation dialog and then said the feature would be implemented soon, because there was no endpoint behind it — a member invited into an organization had no way out that did not go through an administrator. POST /api/security/org/{id}/leave acts only on the caller's own Member row, so it takes no right of its own: nobody can call it to remove anybody else. It deactivates rather than deletes. recordState.isActive is the soft-delete flag, so the row survives for audit and can be restored, and the employment lifecycle on TeamMember.teamMembershipStatus is a different axis that this deliberately leaves alone. Every live-session, roster and notification path already gates on **************** whose base term is that same flag, so clearing it ends chat presence, mentionability and assignment eligibility without further work. Sessions already open against the membership are revoked the way off-boarding does it, so access ends now rather than at the sliding TTL. Three memberships may not be given up, each refusing a different kind of damage: the organization serving the request (leaving it strands that request's own session), the caller's original organization (where the user account is anchored, with no re-invite path back), and one the caller owns (which would leave it with nobody able to administer it).

  21. · kamo-internal

    Rebuild /hr/legal-compliance as the legal registry

    The surface was a MUI tab strip over two AG Grids. Packages rendered as seven columns of counts and an actions cell of six unlabelled icon buttons, three of which disabled themselves without saying why — the only way to learn that Publish needs a binder was to add one and watch the button wake up. - A compliance meter opens the page: every assignment in the org as one stacked bar over four buckets, built from seven total-only counts because DocsService has no aggregate endpoint. Its legend rows are the controls (the bar itself is aria-hidden decoration), and choosing one filters the assignments through ?bucket= — a link an admin can send. - Packages become a deck of cards carrying what a table had to drop: the version as a medallion, the real **************** spine, audience, coverage, and the published content fingerprint as a monogram. Every blocked action stays visible and states its reason as text, never as a tooltip on a disabled control. - Assignments stay a grid, now with a leading state rail, the member over their address, signature progress as a bar, and a status SENTENCE in the state's own colour instead of a grey chip. - One vocabulary end to end (publish / deliver / reissue / withdraw), matching the backend's own verbs. Interaction colour is --legal-brand throughout, per the house rule that brand owns every hover ring and focus outline; --legal-seal stays for identity and state, so a white-label org still sees its own colour on this page. Every rule the surface draws is a pure function in app/lib/legalRegistry.ts with 36 tests, including a proof that the four buckets cover the seven assignment statuses exactly once — without it a status added to the backend would drop out of the meter silently, leaving the bar full width and part of the org uncounted. buildAssignmentQuery now sends statuses as REPEATED params so a whole bucket is one request; a CSV would arrive at DocsService double-encoded as one literal token.

  22. · kamo-marketing

    Play the kamo13 reel

    Points the hero at the new reel's ladder, MP4 fallback and poster. The source is spec-identical to the one it replaces (H.264 720p24, 66s, ~30 MB), so nothing about the player or the ladder changes.

  23. · KlusterServices

    Swap the hero reel to kamo13

    kamo13.mp4 uploaded to MinIO at public/kamo13.mp4 (30 MB, sha256 174aaec6…, verified byte-identical to the local source after transfer — this link has a history of mangling large copies). The old kamo12 objects are left in place: nothing is deleted from MinIO, and they are the rollback path. The new source is spec-identical to the one it replaces — H.264 720p24, 3.56 Mbps video + 256k AAC, 66.005s — so the ladder needs no retuning. Still no 1080p rung; the source is 720p and one would be pure upscale. The encode job keys on the SHA-256 of the source bytes, so pointing it at a new object is enough to make it re-encode; it will build public/hls/kamo13/ and publish kamo13-poster.jpg/.webp on the next deploy. Also documents an ordering caveat the swap exposed: the deploy step rolls the Deployment out before running the encode job, so the new pod starts while loop.mp4 still belongs to the previous reel. Only the /live/ channel is affected, and only by segment jitter — the /vod/ ladder the homepage plays is built afterwards and is unaffected. A rollout restart picks it up.

  24. · kamo-internal

    Rebuild /hr/training as a training console

    The tab was two AG Grids under a tab bar: accurate and mute. It now opens on the org's whole training obligation and lets an admin act on the gaps. - A masthead whose cohort rail IS the assignment population — passed, overdue, in progress, not started, excused — sized in proportion and drawn on load. Every segment deep-links into Assignments pre-filtered, so the chart is the way in to the work rather than a picture of it. - The version ledger: one solid tick per published version with the live one lit, continuing into a dashed stub when the draft has drifted past it. A course publishes as versions and learners are pinned to the one they were sent — that was previously a line of text read one row at a time. Carried by both the gallery and the table, so the two views share one vocabulary. - Courses become a card gallery with search, status chips, sort and cover art; the grid stays behind a toggle for dense scanning. Both read from shapeCatalog, so they cannot disagree about what matches. - Assignments gain search, multi-select with bulk remind/extend/excuse/ cancel, and CSV export. Each bulk button states how many of the selection it can actually touch — a Remind 40 that mails 31 is a lie the admin then acts on. - A third sub-tab, Insights: pass-rate and average-score rings, the chasing queue, member standings, recent activity. All of it comes from the training block of /api/docs/hr/summary, which the HR Overview already calls; DocsService is untouched. - coverImgId has been on the DTO and in the save payload since the module shipped with nothing that could set it and nothing that read it. The editor gets the picker that makes the field real. Four defects fixed along the way: - Publish was disabled on every catalog row and always had been. publishBlockedReason walks course.items, but list() maps rows through toDto(course, false), which never sets them — so the item walk saw an empty list and returned 'empty' for every course, including ones that publish perfectly from the editor. catalogPublishBlockedReason gates on the counts the list does send; the per-question refusals stay in the editor, where the questions exist. - The delete confirmation's message was the courses empty-state body, pasted, so it read 'Build a course from slides and multiple-choice tests, then assign it to your team.' - Assignment status chips rendered status.replaceAll('_',' ') — the raw database enum, in English, in all 22 locales. - Two window.prompt() calls collected a compliance deadline and a waiver reason in an unstyled OS box. Both are real dialogs now; the waiver reason is what an auditor reads. Rules live in app/lib/trainingConsole.ts under 36 tests: vitest here runs in the node environment and collects only .test.ts, so a rule written inside a .tsx is a rule with no test. Strings landed first in kamo-translation-dictionary b5ed84af.

  25. · kamo-marketing

    Wire the offer banner, fix dead Learn more links and the hexagon field

    Five fixes, all of them things that looked present but did nothing. OFFER BANNER — HeroOfferBanner was dead code; nothing imported it. Restored, but driven by whatever the pricing API actually returns rather than by the old hardcoded PROMO_CODE plus FALLBACK_PROMOTION. That fallback is why it died: its end date passed and the component then rendered nothing forever. Now publishing a promotion in the backend makes it appear on the next revalidate and letting it lapse removes it, with no deploy either way. Promotions already come back alongside the plans, so the banner costs no extra request. The selection filters to FLAT_PER_SEAT, which is a correctness guard rather than a preference: the banner's copy reads "Save $N/user", so a PERCENTAGE promo would render "$20/user" for what is really 20% off. It also requires a real endDate — a countdown needs something to count down to, and the previous version faked one with the year 2099. LEARN MORE — the indicator on every home feature card was a bare <div> on a nothing. It is now a real Link with a stretched ::after so the whole card is the hit area, pointing at a new per-category anchor on /features. The six category slugs are a public contract now, and are commented as such. HEXAGON FIELD — `.hexagon-grid` declared `position: relative` while its only usage asks for `absolute inset-0` via utilities. This file loads after the Tailwind import, so at equal specificity the stylesheet won: the element collapsed to zero height and clipped its own ::before through `overflow:hidden` and `contain:paint`. The field had been rendering nothing at all. VIDEO CUT OFF — the reel's frame overhung the viewport by 1.5–5rem so it would visibly run off-screen. That reads fine for decoration and badly for a product demo, where the cropped strip is content someone is meant to watch. It now ends flush with the edge: still no right-hand gutter, so it keeps reading as a window rather than a card, but the whole reel is visible. Also: a restart control beside the sound button, and Integrations 64 -> 100+. Both video controls are icon-only now, which is a fix rather than a style choice. heroVideoMute/heroVideoUnmute were missing from the translation dictionary, so next-intl rendered the raw key path — live HTML was serving **************** and the hero rebuild turned that into visible text on the button. The keys are added to kamo-translation-dictionary (f4d2b65b); icons plus an aria-label keep a copy gap from becoming furniture. The countdown's 50ms tick is 20 renders a second forever on a page that is also decoding video, so anyone asking for reduced motion gets a 1s clock and loses the millisecond unit.

  26. · kamo-login

    Rebuild the welcome panel around a live status rail

    The left column was a generic headline, a security platitude and a pile of ambient decoration (dot grid, floating orbs, a pulsing dash). Replace it with one shared WelcomePanel component: a weight-contrast Geist lockup, a hairline rule, a single line of orientation copy, and a monospaced rail that reports what is actually true about the sign-in — whether the connection is encrypted, which host you are on, and where you are in the flow. The recovery screen uses the same panel with its own copy and step mapping so the two screens stay one surface.

  27. · kamo-marketing

    Rebuild the hero as an editorial split with the reel as an aperture

    The reel was a centred rounded rectangle with a drop shadow — an object placed on the design rather than part of it — and it was the ONLY thing in the hero: ff04781 deleted the badge, headline, description, CTAs and stats when it landed the video, which left HeroHeadline.tsx orphaned and ten copy keys unused. So the section had no thesis and no call to action, which is most of why it read as a square in the middle of the screen. Now a two-column composition: eyebrow, headline, lede, CTA and the free-forever note on the left; the reel on the right, running PAST the right edge of the viewport. Three things do the integrating: - the frame's corners are chamfered at 45°, the same geometry as the hexagon field already behind it, instead of the border-radius every SaaS hero uses; - a light rail down its left edge carries the olive of the headline's travelling pass, tying the two halves of the split together; - it bleeds off-screen rather than stopping short, so there is no fourth edge to make it read as a card. The section's overflow-hidden is what clips it. The rim is a 1px gradient showing through the parent's padding box, because clip-path cuts borders off at the chamfer. Both boxes repeat the polygon — clip-path does not inherit, and a square inner box pokes its corners through. Detail work: the sound control moved to the bottom LEFT, since the frame's right edge is deliberately off-screen and a control anchored there would be unreachable. A progress rail along the bottom shows position in the loop — the only cue that the reel is finite rather than an animated background. Stats are a hairline rule under the composition, with no count-up: four figures racing on load compete with the reel at the moment it should be watched. "Watch Demo" is gone as a CTA because the reel beside the button is the demo; the secondary link reuses the CTA strip's own "View Pricing" label and target so the page names the action one way. No new copy: every string already existed in en.json. i18n/request.ts already deep-merges each locale with English, so the stat labels missing from 21 locales render in English until the dictionary CI catches up. Dead code removed along the way: an accent-glow div keyed off a `group-hover` with no `group` ancestor, an unused videoContainerRef, and object-contain on a 16:9 source inside a 16:9 frame.

  28. · kamo-marketing

    Play the reel as an adaptive HLS ladder instead of a 31.8 MB MP4

    The hero pointed a bare <video src> at a single 3.9 Mbps 720p24 MP4, so a phone on a weak connection downloaded exactly the same stream as a desktop on fibre, and kept downloading after the visitor scrolled past. It now plays the 720p/540p/360p ladder built by KlusterServices' hero-hls-encode job: the player takes the rung its connection carries and fetches ~4s segments only while watching. The 360p rung is 5.4 MB against the original 31.8 MB. Playback moves out of the JSX and into an effect because choosing between HLS and the MP4 is a runtime decision. Safari and iOS get the playlist natively (iOS has no Media Source Extensions, so hls.js is not an option there); everyone else gets it through a dynamically imported hls.js that stays out of the entry bundle. A fatal hls.js error, an unsupported browser, or a failed chunk load all land on the original MP4, which is still the source of truth. The <video> deliberately carries no `src` — declaring one would start the 31.8 MB progressive download this change exists to remove. hls.js becomes a direct dependency. It was only present transitively via @react-three/drei, which is no longer in package.json at all; npm install pruned its 67 leftover packages from the stale lockfile. Nothing in app/ imports drei, three, or react-world-flags (FlagEmoji.tsx documents replacing the last of those on purpose). The poster attribute also starts working: it has referenced public/kamo12-poster.jpg since this component was written, but the object never existed and the hero rendered blank until the first frame decoded. The encode job now publishes it.

  29. · KlusterServices

    Deploy a streaming server and an ABR ladder for the hero reel

    MediaMTX serves live channels over HLS, WebRTC, RTSP and RTMP, and loops the marketing reel as an always-available channel via alwaysAvailableFile — no ffmpeg sidecar, and any RTMP/RTSP publisher transparently takes the channel over and reverts to the reel on disconnect. The homepage video itself is NOT served through MediaMTX. MediaMTX is a live server with no VOD mode; looping a file into a live channel would drop every visitor into the middle of the reel instead of the first frame. So hero-hls-encode-job builds a 720p/540p/360p HLS ladder from the same MP4 and publishes it to MinIO, routed as /vod/ on the streaming host. Measured against the 31.8 MB single-bitrate original: 18.8 MB at 720p (SSIM 0.989), 9.8 MB at 540p, 5.4 MB at 360p, in ~4s segments the player fetches only while watching. The job is ETag-idempotent, so it no-ops on every deploy that is not a video swap, and it also publishes public/kamo12-poster.jpg — referenced by the homepage since it was written, but a 404 until now. Routes live on stream.* and theme.*: theme.<domain> already has DNS and a certificate for every tenant, so this works today, and the stream.* form activates on its own once a DNS record exists. The WebRTC route matches **************** rather than a prefix deliberately — MediaMTX serves WHIP on the same port, and a prefix match would publish an unauthenticated ingest endpoint to the internet. This touches .forgejo/, which forces FULL=true and a full-cluster reconcile. Audited first per the documented procedure: kubectl diff over all 112 apply targets is clean on 104; five are these new files, two are the nats/mail bootstrap Jobs the workflow deliberately deletes and re-applies, and mail/mysql-permissions-job.yaml fails as an immutable completed Job, which the workflow already tolerates with `|| echo`. Every kubectl delete target was confirmed absent.

  30. · KlusterServices

    Workstation-side mount script for the KAMOTEAM share

    NFSv4 over the NodePort, mounted soft with a systemd automount so an unreachable cluster returns an error instead of hanging every process that touches the folder, and so the laptop still boots normally when off the LAN.

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