Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-shared-library
Count today's spend across a member's products in one query
Manage-Credits wants to show, per member, how many leads they have accepted so far today — the running total the Max Daily Spend cap is compared against. **************** answers for one product, so a grid would pay a query per member x product. Same dateSpent window, grouped by product. Deliberately not a second definition of "today": the caller passes the member's own local start-of-day, exactly as CreditLeadAssignmentService does when it enforces the cap, because a figure displayed beside the cap that counted a different day would be worse than none.
· kamo-shared-library
Count the assignable pool for every vendor product at once
countAssignablePool answers for one product, so a caller can only ask about products it already holds. Manage-Credits was iterating allotments to decide what to ask about, which meant a product nobody held a credit rule for reported no available leads — however many were sitting in its pool. Eleven freshly created products showed 0 against pools of up to 6,730. Availability is a property of the (product, market) pool, not of anyone's credit rule, so it has to be answerable without one. One grouped query rather than one count per product.
· InitializerService
Composite indexes for geolite_locations
Country alone was the only index on an 80,429-row table. Measured on production: city resolution (country + subdivision) index-scanned all 18,870 US rows to keep 984 for US+CA (138 ms), and the country picker sequential-scanned all 80,429 rows to return 251 (168 ms). Purely additive, idempotent, same 40001 retry as the sibling index migrations. Near-free on this table — it is replaced wholesale by the weekly GeoLite sync and never written on a request path.
· kamo-internal
Show the requestor's org, and offer the System User door to their agent
The support chat header now names the organization the request came from, to everyone working the conversation and to nobody else — the person who raised it would just be reading their own letterhead, and it pushes the chat down the window. Next to it, for an agent the server says is entitled, a button that opens that organization in a new tab signed in as the System User. The whole predicate lives server-side and arrives as one flag; re-deriving any of it here would make a second copy of a security rule free to disagree with the first, and the copy that drifts is the one on the screen. systemUserAccess.ts owns the two things the client does decide: the flag is read as `=== true` (absent means the server built the payload for nobody, which is not a decision), and the requestor is never offered the button. Member ids are compared as strings — they exceed JavaScript's exact integer range, and two neighbouring members round to the same number. The exchange itself is the /network ENTER AS SYSTEM MEMBER flow verbatim, including claiming the tab synchronously before the await: a window.open that runs after one is a popup the browser never saw a person ask for, and Safari blocks it. Interaction Center → Chats → Support rows gain the source org, a status chip and the ticket's age. The chip is the same StatusBadge the ticket window and /support use, so one status never wears two colours in two places; its label map is now exported so the row's aria-label reads the same words rather than keeping a second list of the same fifteen statuses.
· MediaService
Name the requestor's org, and end System User access when the ticket moves
Three things, all about a support desk that answers more than one tenant. The ticket detail now carries a per-viewer verdict on whether to offer "open the requestor's org as the System User", resolved by the shared SupportSystemAccessResolver — the same class SecurityService re-runs before minting anything, so nothing here can grant access, only reveal it. The one condition added locally is about the CONVERSATION rather than the grant: never the requestor's own button. It could not be minted anyway, but a support window offering the customer an administrative door into themselves is alarming whether or not the door opens — and on a widget ticket the "requestor" is the org's system member, who is usually the person working the queue. MediaService is also where that access ENDS. Reassigning, escalating, moving an assigned ticket and closing or resolving one all recheck the agent who lost it against the requestor's organization; the revoker re-derives from the database, so an agent with another open ticket from the same org — or the blanket SYSTEM_USER right — keeps their session and no caller has to work that out. Rechecks run afterCommit: re-reading rows this transaction just wrote is what raises a 40001 restart on YugabyteDB, which would take the reassignment with it. The previous agent keeps the CHAT, deliberately — that is the point of reassigning rather than reopening — but not the tenant. Every mutation endpoint re-answers the verdict on the way out, because the window swaps in whatever they return and toDTO knows nothing about who is looking; without it the button vanished the moment an agent touched the priority dropdown. It is a void side effect rather than a wrapper that returns the ticket: the wrapper form turned the entire response body into null wherever this service is mocked, which the controller tests caught. Finally, requestorOrgName / requestorOrgIsPlatform on the ticket (bulk-loaded on the list path — requestorOrg is lazy and a queue is rows from different orgs by definition), and org, status and ticket age on the Chats-feed support rows. Age is the ticket's creation, not lastActivityAt: a quiet three-day-old ticket reads as fresh by activity and is the most urgent row on the list.
· SecurityService
Mint a System User session for a support agent's open ticket
enter-as gains a second reason to say yes. SystemUserEntryPolicy turns the caller's *** into the facts the shared SupportSystemAccessResolver takes, and that resolver — the same one MediaService uses to decide whether the support window shows a button — is the only thing consulted. The supportTicketId in the request body is advisory: it goes on the audit line and is not an input to the verdict, so naming somebody else's ticket, a closed one, or a random UUID changes nothing. A ticket-scoped session is registered with SupportSystemSessionRevoker before its one-time key exists, because the OTK is what puts it in a browser and a session that reached a browser without being findable could only be ended by its TTL. A blanket-right session is deliberately NOT registered — nothing about it can expire, and tearing it down on a reassignment would evict somebody entitled to be there. SupportGrantSweep re-derives every registered grant on a timer as the backstop for "MediaService died between reassigning the ticket and revoking the session"; it logs at WARN, because reaching it means a push was missed. Two things fixed on the way past: - The System User gate used **************** which reads administrator ROWS only. The /network button is drawn from platform-access/me, which also honours the top-level-owner and active-god-mode floors — so the platform's own owner saw ENTER AS SYSTEM MEMBER and got a 403 from it. Both sides now resolve the same way, which is what that class claims as its invariant. - Session fact parsing (memberID, orgID, isOrganizationOwner, GD+godModeActive) is now SessionPlatformFacts, read once. Two of those are floors that grant every platform right, and a second reader that got godModeActive slightly wrong — GD alone, say — would hand out the platform. Response now also carries the destination url. /network builds its own from the org row it holds; a caller coming from a support conversation has a ticket and no domain.
· kamo-shared-library
Let an open ticket grant its agent the requestor's org, and expire with it
A support agent needs to look inside the customer's tenant to answer the ticket they are holding. Until now the only way in was the blanket SYSTEM_USER platform right, which is all-organizations and permanent — far more than the job needs. Adds a second, much narrower grant and puts both behind one resolver: - SupportSystemAccessResolver answers "may this member enter that organization as the System User", by the blanket right OR by an open ticket assigned to them and raised from that organization. MediaService runs it to decide whether to draw a button; SecurityService runs it again from scratch before minting a session, so the button and the action cannot disagree about who may press it. Preconditions hold on both paths: an active TEAM member of the top-level org, never the System User itself, never the top-level org as a target, never your own org. - The ticket grant is DERIVED, never stored, so it ends the moment the last open assigned ticket from that organization is reassigned, resolved, closed or voided. Nothing has to remember to take it away. - A session already in a browser is not asking anybody anything, though, so SupportSystemSessionRevoker indexes the ones minted this way and destroys them when the grant lapses. Deleting the *** key is the only universal kill switch — every service reads sessions from the same Redis. Key names live in SupportSystemSessionKeys because two services write and read them, and a typo would present as an agent quietly keeping access to a customer's business. Not a @Service: it needs a RedisTemplate, and a shared stereotype requiring one takes down every service that has no Redis on its next deploy. - **************** is the one definition of "open" the grant hangs on. PRE_TICKET counts (a chat request is the same unit of work); resolved does not. Also carries requestorOrgName / requestorOrgIsPlatform on SupportTicketDTO, and status, age and requestor org on the Chats-feed support rows — a support queue that answers more than one tenant is unreadable without them.
· InitializerService
Index member_rights_applied on (member_id, the_right)
The table had no indexes at all, while every finder on it filters by MEMBER_ID. At 10,524 rows a single member's rights lookup was a full sequential scan — measured on production at 368 ms to return 192 rows, on a table that grows with members x rights. Purely additive, idempotent, with the same 40001 retry as the lead grid indexes, since it contends with Hibernate's own ddl-auto pass in the same boot.
· InitializerService
Composite lead indexes, and unblock the run that creates them
The /leads grid now filters and pages in the database instead of shipping the whole organisation to the browser. With only IX_LEADS_ORG_ID that would relocate the scan rather than remove it: the planner still reads every lead in the tenant and discards what the filter rejects. Four indexes, each leading with ORG_ID so Yugabyte keeps hash-partitioning on the tenant and then narrows: assignment (the filter behind the original report — 801 of 827 leads in the largest org were unassigned, and a member filtering to their own book wanted seven rows out of 1,427), market and status (the tab bar, applied on essentially every load), and dateCreated (the default sort and the date-range filter). Hibernate's ddl-auto would create them from the @Index annotations in the same boot, but it emits CREATE INDEX without IF NOT EXISTS and swallows the failure, which makes 'did it actually land' unanswerable from the log. Explicit, idempotent and retried here instead. Purely additive, so the gate-the-DROP rule does not apply — but the 40001 retry does, since this contends with that same ddl-auto pass moments earlier. IX_LEADS_ORG_ID is left in place though now redundant: dropping an index a racing pass may have just failed to recreate is how a table ends up with neither. LeadContactPointSeedRunner had to change for any of this to run at all. It paged with findAll(Pageable), which Spring Data executes as a content SELECT plus a COUNT in one read-only transaction — so the COUNT is never the first statement, and Yugabyte can only transparently retry a read restart on the first statement. Because the runner writes contact points between pages it manufactures its own restart. Latent until there were enough unseeded leads to matter; at 851 it failed outright: 40001 Restart read required ... query layer retry isn't possible on: select count(l1_0.uid) from leads That aborted the whole run at order 99 and took every later migration with it, including this one. Replaced with a keyset scan (uid > :afterUid, one statement per batch, no COUNT), which also survives inserts mid-scan in a way OFFSET paging never did.
· kamo-internal
Offer Company Name as a mappable import field
leadFields.ts is the list the mapping canvas draws from, and it must mirror the backend switch in LeadImportController — a path missing from either side is accepted by the UI and then silently ignored. companyName was missing from both; the backend case landed alongside this. Markets configured with includeCompany = true (B2B farm lists) previously had no column to map the business name onto.
· SecurityService
Map companyName so a spreadsheet can carry the business name
setDirectFieldOnCreateRequest had no companyName case, so the field was unreachable from the manual-import wizard even though CreateLeadRequest carries it and LeadService.createLead already persists it (truncated to 50 chars). On a market with includeCompany = true that left B2B imports with no home for the business name at all. Pairs with the matching case in DaemonService's LeadIntakeProcessingJob and the new field entry in kamo-internal's leadFields.ts — a path not present in both the frontend list and the backend switch is silently ignored.
· DaemonService
Let a mapped payload carry companyName and source
The intake job's direct-field switch stopped at vendorLeadID, so a mapping onto companyName or source was accepted by the UI, stored on the endpoint and then silently dropped when the payload became a CreateLeadRequest. Both fields already exist on the DTO and are already honoured by LeadService.createLead — only the job's switch was missing them. This matters for company-shaped markets (includeCompany = true), where the business name is the lead's identity and there was previously nowhere to put it.
· kamo-marketing
Put the headline's swept band back
Restored exactly as it was — same two-layer gradient, same 120deg band, same 9s/1.1s timing and easing — after removing it in the compositing pass. The cost is precise and worth stating rather than leaving to be rediscovered: it is the one animation on this page that cannot be composited, because a text-clipped sweep is moving paint inside glyph shapes and no transform or opacity expresses it. Measured with getAnimations(): desktop 2 -> 3 (the extra one is this) mobile 1 -> 1 (unchanged) Mobile is unchanged because the =<767px kill-switch already sets `animation: none` here, and mobile is where the performance score is measured. A phone renders the resting fill and nothing loops. The other 25 elements from the original audit stay on the compositor. The comment on the rule now says all of that, including that the masked-overlay workaround was tried in this codebase and measured worse (34 -> 41), so nobody re-derives that the hard way. Verified by scrubbing the animation to the frame where the band crosses — solved for it rather than guessing, since the easing is front-loaded and eyeballed timings kept landing after the pass — and screenshotting at 2x: the olive band is on the leading edge of the headline, absent in the parked frame.
· kamo-internal
Retitle Modified to Converted and fix two tab icons
Both tabs wore an icon that already means something else in this grid: the Modify tab used GetApp, the row-level download button, and Converted used EditNote, the row-level open-in-editor button. They now carry DesignServices and PublishedWithChanges. The panel heading inside the Modify tab still read "Prepare Selected" in hardcoded English; it reuses the tab's message so the two cannot drift.
· kamo-internal
Choose which page a tracking link lands on
A code is not tied to a page — the marketing site captures it site-wide — so the only real question when sharing one is where the visitor should land. The product had no answer for that: the member got a single link to the home page and had to hand-edit a URL to send anyone to pricing, which is exactly where a typo silently costs a referral. TrackingLinkPicker offers all six front doors for one code, each one click from the clipboard: home, pricing, integrations, the early-bird offer, contact and book a demo. It opens the moment a code is minted — generating a link and getting one you can paste are the same errand — and again from the row menu, which now reads "Get links" instead of copying the home-page URL outright. /offer is deliberately NOT one of them. It answers HTTP 200 and serves the "Not Found" page, which is the worst kind of broken link: nothing reports it and the visitor just leaves. The real page is /pricing/offer, and the list is a typed module with that reasoning written down so nobody adds the pretty path back. The whole row is the button — a 24px copy icon beside 400px of inert text that plainly looks clickable is a target people miss — and confirmation is per row rather than a toast, because one toast cannot tell six near-identical links apart. The icon chip swaps to a check, a brand underline sweeps under the ?discount= segment, and the entrance stagger is capped so the sixth row is never late enough to read as a slow dialog. Reduced motion turns all of it off. The create dialog's own success state is gone with this: it showed one link and is now unreachable, so it and its imports are removed rather than left to rot.
· kamo-marketing
Show a still of the reel instead of a grey pulse
Adds public/img/hero-still.webp — 1280x720, matching the aperture's 16/9 exactly — and makes it what the hero shows until someone presses play. This also fixes a bug the tap-to-play change introduced and I missed. The <video> element carried `opacity: reelWanted ? 1 : 0`, which was right while the reel started by itself: the element appeared once the gates opened, poster and all. With playback now gated on a tap, `reelWanted` stays false for every visitor who never presses it — so the element, and therefore its poster, was invisible for the entire visit and the aperture showed the skeleton pulse instead. It keys on the poster being ready now. The skeleton is correspondingly reduced to what it was always meant to be: the gap before the still arrives, rather than something waiting on `canplay`, which never fires under `preload="none"` until the reel is asked for. Three smaller changes follow from the still being content rather than a placeholder: · Local, not the theme bucket's kamo13-poster.jpg. For most visitors this frame is the hero for the whole visit, so it ships with the app. · Quality 50, not 30. Thirty was chosen for something a video painted over within a second; artefacts in this are artefacts in the page. Through the optimizer it is 1.5-3.7 KB of AVIF depending on the rung — the source is 22 KB of WebP. · No more idle gate. In-view still earns its keep, because on a narrow viewport this aperture sits below the fold under the copy column. Waiting for the main thread to go quiet on top of that was caution owed to the multi-megabyte video behind it; making a ~2 KB image arrive late now just means a longer look at nothing. Verified in Chrome: before the tap the poster is set, the element computes to opacity 1, the skeleton is absent and zero media requests have been made; after it, currentTime advances 1.1 -> 3.6 -> 8.6s with muted false. Console and DevTools Issues clean, 22 requests, all guards pass.
· kamo-internal
Build the shared link as ?discount=<uuid>
buildTrackingLink emits the new parameter, and the link strip highlights the ?discount= segment it used to highlight as ?mrt=. The token itself is now an opaque UUID minted server-side, so nothing here derives or formats it. Also renames a keyframe and a drawer title id that still carried the old prefix.
· kamo-register
Read the discount cookies and send the discount* fields
Follows the marketing site: kdsc_vid / kdsc_src, with ?discount= as the query-param fallback for a visitor who lands on the register form with the link still in the address bar. The body fields become discountVisitorGuid / discountCode; SecurityService reads both names for a release so the two deploys do not have to be simultaneous.
· kamo-marketing
The referral parameter is ?discount= and the token is a UUID
The link a member shares is **************** It used to be ?mrt=<memberId>.<iteration>, which put a real member id in every shared URL and let anyone enumerate that member's other campaigns by counting up from .1. The validator changes shape with it — a UUID pattern instead of digits-dot-digits — and keeps doing the job it was added for: validate, never truncate, because slicing by UTF-16 code unit can halve a surrogate pair and encodeURIComponent throws URIError on a lone surrogate inside middleware(), which is a 500 on the page. Hex and hyphens cannot express a surrogate. Cookies follow the vocabulary: kmrt_vid/kmrt_src become kdsc_vid/kdsc_src, the beacon is DiscountBeacon, the collector is /api/discount and the guard is check-discount-cookie. Half-renaming would have left a ?discount= parameter stored in a kmrt_ cookie read by MrtBeacon, which is exactly the drift that costs someone an hour later. The cookies shipped hours ago, so almost none exist in the wild to orphan. Guard fixtures now assert the new shape and, specifically, that the OLD memberId.iteration form is rejected.
· SecurityService
Accept the discount* signup fields, keep the mrt* ones working
The register site now sends discountVisitorGuid / discountCode. Both names are read, new first, because the two services deploy independently — for the minutes between rollouts one side is still speaking the previous vocabulary, and the cost of getting that wrong is a signup that silently earns its referrer nothing. The mrt* fallback can go once both sides have been out for a release.
· MediaService
Mint an opaque UUID token, in one insert
createCode generates the token itself, so there is nothing to read first and nothing for two tabs to collide over. That removes the whole apparatus the old sequential token needed: the max(iteration) + 1 read, the collision-detecting retry, and the REQUIRES_NEW TransactionTemplate that retry needed because a unique_violation aborts the transaction it happened in on YSQL. One save, done. A UUID collision is not a case worth writing code for; the unique index on CODE stays as the backstop so an impossible one fails loudly rather than quietly attributing two members' traffic to one row.
· kamo-shared-library
Make the shared token an opaque UUID
The public token was <memberId>.<iteration>, which was wrong on two counts. It published a real member id in every link anyone shared — on a business card, in an email signature, in someone else's inbox — and it made the whole space walkable: one link told you the member, and counting up from .1 enumerated their other campaigns. A competitor could see how many someone runs, and anyone could forge a plausible code for a member they had never met. CODE now holds a random UUID and the URL parameter is ?discount=. The column, its length and its unique index are unchanged; only what goes in it moved. ITERATION and uq_mtc_org_member_iter are gone with it. Their only job was sequencing that token, and sequencing is what forced max(iteration) + 1 — a read-then-write two tabs can both win, which needed a unique constraint to catch, a retry to recover, and that retry in a BRAND-NEW transaction because a unique_violation aborts the transaction it happened in on YSQL. All of it existed to defend a number nobody needs. A UUID has nothing to collide over; the unique index on CODE stays as the backstop. The token is deliberately still a column rather than the row's primary key, even though both are UUIDs. Keeping them apart is what makes a link reissuable: a token that gets scraped, spammed or printed on the wrong flyer can be replaced in place while the visitors, events and conversions already attached to the row stay where they are — and it keeps the id the authenticated API addresses out of public URLs.
· InitializerService
Retire ****************
Tracking codes are shared as ?discount=<uuid> now. The old <memberId>.<n> token published a real member id in every shared link and made the space walkable — one link named the member, and counting up from .1 enumerated their other campaigns — so the public token moved to an opaque UUID in the existing CODE column and ITERATION has no remaining job. This cannot wait for Hibernate: ddl-auto adds columns and never drops them, and ITERATION is NOT NULL. The entity stops mapping it in the same change, so from the moment the new shared library ships every INSERT omits a column the table still demands. Dropping it is what lets the mint path write at all. The composite unique constraint goes with it — it existed only to stop two concurrent mints taking the same iteration, and a random UUID has nothing to collide over. Order 0 for the usual reason here: any runner that loads a marketing entity emits the mapped columns in its SELECT.
· kamo-marketing
Serve a real per-request CSP nonce with strict-dynamic
Clears both remaining High findings on script-src: the 'unsafe-inline' one and "host allowlists can frequently be bypassed". Only a nonce with 'strict-dynamic' clears those, and Next cannot supply one on a prerendered page — a nonce must be unique per response, so Next emits one only while rendering, and a prerendered route is served from the full-route cache without rendering. The documented workaround is to make every route dynamic, which would undo the static rendering this site depends on and collapse <Link> prefetch to a bare route tree. So the nonce is applied where the bytes leave the process. server-nonce.js starts the standalone server on loopback, serves the public port itself, and stamps each HTML response on the way out. Next keeps prerendering and keeps serving from its own cache; measured through the wrapper, /en/pricing still prefetches 77,521 bytes with 23 chunk references and answers x-nextjs-cache: HIT. Static rendering and a real rotating nonce, both. 'strict-dynamic' is what retires the allowlist finding: browsers ignore host allowlists under it and trust scripts created by already-trusted scripts. The chat widget and the analytics tracker are injected by next/script at runtime, so they are trusted transitively and need no allowlist entry at all. 'unsafe-inline' and https: remain only as the documented fallback for browsers too old to understand nonces, which ignore neither and were getting nothing before. Three things this turned up, none of which would have been visible from the config: · Forwarding the request CSP upstream is a trap. Next reads a nonce out of it and stamps its own scripts — then that render goes into the full-route cache with the nonce baked in, so every later visitor gets 96 tags carrying a dead nonce. Under 'strict-dynamic' a stale nonce does not degrade, it blocks: a blank page until the entry expires. The request CSP is stripped instead, and any pre-existing nonce attribute is removed before stamping. · Rewriting the upstream Host to loopback breaks redirects. Next builds absolute redirect URLs from Host, so `/` answered `Location: http://localhost:41337/en`. The Host is forwarded unchanged, and a redirect pointing at our own loopback is collapsed to a path as insurance. · Compression had to move. Upstream is asked for identity so the HTML can be rewritten without a decompress/recompress round trip, and the wrapper re-compresses — brotli where the client takes it, which is 30,177 bytes against the 56,324 gzip served before. scripts/check-csp-nonce.mjs holds the contract end to end on every build: boots the built app behind the wrapper and asserts the header carries a nonce and 'strict-dynamic', that EVERY script tag carries a nonce, that all of them are that response's, and that it rotates between requests. It caught the cached-nonce bug on its first run. A fixed nonce would satisfy the audit while being readable by anyone, so the rotation check is the point rather than a formality. Memory request 128Mi -> 256Mi: HTML is buffered to be stamped, so peak memory now scales with concurrent HTML requests instead of sitting flat.
· kamo-marketing
Enforce Trusted Types, path-scope the script allowlist
Clears the "No Content-Security-Policy header with Trusted Types directive found" finding by moving require-trusted-types-for into the enforced policy. This is evidence-backed rather than a second guess. It shipped report-only first and the console came back with ZERO Trusted Types violations, which is exactly the proof needed: report-only logs a violation for every sink reached without a policy, so no violations means the default policy was installed ahead of all of them. What made that true was dropping next/script for a plain inline <script> in <head>. check-trusted-types-order.mjs holds the placement. Also narrows script-src from hosts to paths, which is the "host allowlists can frequently be bypassed" finding taken literally. On theme.kamocrm.com that was not hypothetical: it is the org theme bucket, white-label tenants upload their own assets into it, and `script-src https://theme.kamocrm.com` therefore let any tenant drop a .js file in their own bucket and have this site execute it. Only /public/widget/ is executable now — the loader and its hashed bundle both live there — and analytics is pinned to the exact tracker file.
· kamo-internal
Show when a visitor's credit moved to another link
The timeline gains ATTRIBUTION_MOVED, in the warn tone — not a conversion and not a failure, but the moment this visitor stopped being this member's to earn from, which is worth noticing. The event-type union gains the value so the label resolves instead of falling through to the page-view wording.
· MediaService
Move credit on a newer link, and record the moment it moves
The ingest worker now calls reattributeWhileAnonymous after the traffic bumps. The repository method carries the entire rule, so there is nothing to check here and no window to race through: a return of 1 means credit genuinely moved to the link this visitor just arrived on, 0 means they came back on the same one or they already have an account and their sponsoring member keeps them for good. Only the attributed code moves. The visit and page-request bumps have already credited the code that served this pageview, which stays true however attribution lands. A move writes an ATTRIBUTION_MOVED row on the visitor's timeline, after the page view so the two read in the order they happened. It is the only event in this feature that changes what a member is owed without anyone doing anything wrong, so it is worth stating outright rather than leaving to be inferred later. The signup handler resolves the code on the envelope and, when it names a live one, stamps the account and settles attribution in the same statement. That is the case the beacon can miss entirely — idle-deferred, at most once per pageview, and a visitor can follow a link and go straight to the register form. Retired codes resolve to null and leave attribution alone, so a link that stopped earning credit through the beacon cannot start earning it again through the signup door.
· kamo-shared-library
Credit the newest link until an account exists, then freeze it
Attribution was frozen at first touch, which gets the common case wrong: someone who follows member A's link in March and member B's in June, and only then signs up, was brought in by B. First-touch pays A for a visit that led nowhere and pays B nothing for the one that converted, and no downstream arithmetic can repair an attribution that is simply wrong. So credit now follows the newest link the visitor arrives on — for exactly as long as they are anonymous. Once an account exists it stops moving, permanently. That half matters just as much: the sponsoring member has earned it, and if a later link could still move credit then any member could take another's customer by getting them to click once, while org-created and became-paid — which land weeks after the signup — would drift to whoever was most recently clicked rather than whoever actually brought the customer in. reattributeWhileAnonymous carries the whole rule in one conditional UPDATE (different code AND signedUpAt IS NULL AND userId IS NULL), so there is no read-then-write window for two beacons, or a beacon and the signup itself, to race through. The `<>` on the code id keeps it to real moves, which lets the caller treat a return of 1 as "credit actually changed hands" and record it. markSignedUpAttributedTo settles the account and the credit in a single statement, closing the one window the ingest path cannot: the beacon is deferred to idle, so a visitor who follows a second member's link and goes straight to the register form can sign up before any beacon reports that visit. The counters stay safe because the freeze and the first conversion are the same moment — by the time any *_AT column can be stamped, attribution is already fixed for the life of the visitor. Traffic is untouched either way: visits and page requests belong to the code that served them. EVENT_TYPE gains ATTRIBUTION_MOVED so a member whose visitor moved to someone else's link can see exactly when it happened, rather than inferring it from a code id that quietly differs.
· kamo-internal
Move Windows Desktop and Master Machine into Platform Admins
Both are Guacamole targets that share one credential between everybody, which makes them operator tooling rather than a member's own workspace. They were top-bar nav options gated on `ctx.isGod`, so every god carried two icons for machines they rarely touch, both of them draggable, unpinnable and searchable in the command palette like an ordinary feature. They now render as two buttons to the left of "Add platform admin" on Settings -> Platform Administration -> Platform Admins, beside the dev-machine controls that already administer the third KamoDesktop target. The gate is `caller.isGod` from the tab's own payload - the server's reading of `users.is_god`, never a client-side derivation - and DesktopSsoController still requires god on its own, so the buttons only mirror an answer the endpoint gives independently. desktopLinux stays in the nav: it is provision-gated, per-member and personal. Removing the two registry entries removes their nav placement, their launchpad cards, their palette entries and their action keys; resolveNavLayout already drops stored ids that no longer exist, so a member who had arranged them keeps a valid layout with no migration. The devMachineGate test now asserts they are absent rather than god-gated.
· kamo-marketing
CSP and COOP enforced; Trusted Types report-only
Re-lands the security headers after I took the site down with them, minus the one directive that did it, plus a build guard so it cannot happen again. WHAT BROKE. `require-trusted-types-for 'script'` is only survivable if a policy named `default` exists before the first DOM sink fires. Mine never ran. The policy was rendered from app/[locale]/layout.tsx, which looks like a root layout and is not — there is an app/layout.tsx above it — and `next/script strategy="beforeInteractive"` is only honoured in the true root. Next downgrades it elsewhere without a warning. So the page loaded, hydration started, and every sink threw: react-dom on `innerHTML`, Turbopack's chunk loader on `script.src`, nine TrustedScriptURL refusals, then Next's client-side error screen. My reasoning had been that no sink could fire before the policy because all 78 flight-data pushes are in the body. That was the wrong question. The framework's own `<script async>` tags are hoisted into <head> and execute as soon as they download — they do not wait for the body, and they are the ones that threw. MOVING IT TO THE REAL ROOT DID NOT FIX IT. Measured, not assumed: app/[locale]/layout.tsx policy @ 12,678 first async script @ 1,593 app/layout.tsx (root) policy @ 12,678 first async script @ 1,730 React hoists `<script async src>` above anything a layout renders, so there is no position in the tree that wins. The remaining options are worse than the problem: rewriting the HTML stream in middleware puts a per-request transform in front of every prerendered page and breaks streaming, and a nonce-bearing CSP needs headers(), which drops ~200 routes off static rendering and costs the prefetching this site depends on. So Trusted Types ships as **************** The directive is deployed and violations are visible; a report-only header cannot block anything. The Lighthouse item stays red, and that is the honest state rather than a pass-through policy that would make it green while protecting nothing. The guard is the real deliverable. **************** fails the build if the directive is enforced while the policy is emitted after the first async framework script, and prints the two offsets and the fix. It runs on prebuild and in npm test. Nothing about this failure is visible to types, lint or the build — only the byte order of the emitted HTML — which is why it needed a check rather than a comment. Enforced and verified against the real standalone server: CSP with no trusted-types directive, COOP same-origin, Referrer-Policy, Permissions-Policy, X-Content-Type-Options. Audited the served page resource by resource against the finished policy: zero blocked, and 'unsafe-inline' covers all 86 inline scripts.

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