உள்ளடக்கத்திற்குச் செல்லவும்
KAMOUniverse

மாற்று பதிவேடு

என்ன புதிது.

அது கப்பல்கள் போல், தளத்தில் ஒவ்வொரு மேம்பாடு.

  1. · kamo-internal

    A group you can put people on and pay for

    Creating a group produced a name and nothing else — no way to add anyone, no way for its manager to choose a plan, nowhere to pay. Opening a group now shows who pays, who is on it, who is still waiting for a seat, and the plan controls that buy those seats. Creating one asks who pays, using the canonical member picker. It used to send the organization's account uid, which is exactly why every group was billed to the organization; the payer is now resolved server-side from the manager and accountUid is no longer on the wire at all. The roster distinguishes having a seat from waiting for one. A manager who puts eight people on five seats is told three colleagues have no access rather than finding out from them.

  2. · BillingService

    Make billing groups work end to end, and stop self-pay charging for nothing

    A group can now gather people, buy a plan on its manager's own card, and hand its seats out. None of that existed: the only operations were create, approve and delete, so an owner who made a group got a label and no way to use it. BillingGroupSeatService is where money moves and entitlement is issued, kept apart from the record-keeping so no new route can be added that skips the gates. subscribe() refuses before create() is called rather than after, because create() opens a live Stripe subscription — an unapproved group, a legacy one, a manager with no card or a seat cap breach all stop there. Two defects made the group bill the wrong person: - The payer was whatever accountUid the request body named, unchecked, and the console sent the organization's. A group billed to the organization is not a billing group; it is a second name for the organization's own subscription. The payer is now derived from the manager and the two are asserted to agree before anything is charged. Sending accountUid is rejected outright. - ensureAccountForMember reuses the member's oldest AccountMember link, which for anyone ever added to the organization's account is the organization's account. ensurePersonalAccountForMember identifies by primaryMember instead, which is the only field that says an account belongs to a person. Where an owner manages their own group, "their card" and "the organization's card" are genuinely the same Account row and the same Stripe customer, and no code can tell them apart. That is refused unless confirmed, rather than papered over. Separately, two live bugs found while in here: - /self/seat charged the member and never issued them a seat. It called create() and returned; there was no SubscriptionMember row and no AccountLicense, so the buyer stayed unlicensed while their card was billed every month. - @Transactional in AccountBillingService had drifted onto a private method, so Spring ignored it and ensureAccountForMember ran with no transaction at all. A failure between its two saves orphaned an Account the next lookup could not see, and the following call created a second one. /self/coverage is new: it answers "who pays for my seat", which nothing did. It is also what stops someone already covered being offered a second seat on their own card.

  3. · InitializerService

    Schema for groups that can actually pay

    Three runners: BillingGroupRosterMigration creates billing_pay_group_members — whom a group means to pay for, before it has bought anything. org_id is denormalised onto it so the database can enforce "a member is on at most one active billing group per organization" as a partial unique. Two groups paying for one person is two charges for one seat, and nothing else in the schema would catch it: SubscriptionMember's uniques are per-subscription and AccountLicense has none. **************** adds billing_group_uid to account_subscriptions and replaces the three-column unique with two partial uniques. Widening it to four columns would not work — NULLs are distinct inside a unique index here, so every non-group row would stop being constrained. BillingPayGroupColumnsMigration adds market_id, seat_limit and status, and marks existing groups LEGACY. Those were billed to whatever account uid the request body carried, and the console sent the organization's, so their account_uid is almost certainly wrong. There is no safe way to guess which card was meant, and guessing wrong bills the wrong person, so they are refused at the charge point instead of repaired. The first run showed why the retry matters: both replacement indexes failed with 40001 could not serialize access, the runner logged "skipped" and carried on, and the DROP of the old constraint then succeeded — leaving account_subscriptions with no uniqueness at all, which is strictly worse than the collision this fixes. DDL statements now retry on serialization failure, and the drop is skipped entirely unless both replacements are confirmed in place.

  4. · kamo-shared-library

    Let a billing group be its own payer

    A group could be named and nothing else. Its subscription was never set — nothing in the platform called setSubscription — so subscription_uid stayed null forever and the group could not buy or hold a single seat. Three things had to change in the model: BillingGroupMember is new. Seat truth stays where it was, on SubscriptionMember and AccountLicense, but SubscriptionMember.subscription is NOT NULL, so until a group buys a plan there is nowhere to record the people it exists to cover — and gathering people before buying seats for them is the whole point. This is intent; that is entitlement. AccountSubscription gains billingGroupUid, and the (account, market, target org) unique constraint moves out of the entity. A group bills its manager, on the manager's own account, targeting the same org in the same market as any seat that manager already bought for themselves — the same tuple. So a group's first purchase did not fail, it found the manager's personal subscription and rewrote it: plan, seats, Stripe id. No exception, no second row. The rule now lives as two partial unique indexes that JPA cannot express, created by KamoInitializer; the entity declaration had to go or ddl-auto would recreate the old constraint ahead of the runner that drops it. **************** is deleted rather than deprecated. It returns Optional on the strength of a constraint that no longer exists, so once one account holds both a personal and a group subscription it matches two rows and throws at all five call sites across three services. Leaving it to be found by autocomplete was the more dangerous option. Also: allowsGroups() now includes REQUIRES_APPROVAL. Excluding it made the approval path unreachable — creating a group was refused outright, while the code setting the approved flag asked requiresApproval() and so could only ever answer "already approved". An owner who chose oversight got no groups at all.

  5. · kamo-internal

    The HR course editor, assignments grid and member course player

    /hr/training replaces its placeholder with the two sub-tabs the brief asked for, and the member's Assigned Training grid gains the action column it was holding back for want of a player. COURSES. A catalog grid with publish, assign, duplicate, archive and delete inline. Delete is disabled once anybody holds an assignment and the tooltip points at Archive instead — deleting a course somebody was assigned orphans their evidence, since the assignment pins a version whose parent would be gone. "Nothing has changed since version N" is shown as INFO rather than an error: the author pressed Publish on something already published, which is a success they misread. EDITOR. Outline · canvas · inspector as a full-page nested route rather than a dialog — a slide needs a canvas beside an outline and an inspector, authoring is multi-session work that has to survive a refresh, and hrTabMatches was written so a nested route keeps the parent tab lit and MANAGE_TRAINING-gated with no new registration. Saves are per-ITEM and debounced; a whole-course PUT with replace-set semantics turns two admins editing two different units into one silently losing their work. After each flush the course is re-read, because saveItemContent recreates child rows and a stale local copy would send phantom ids on the next save. Reordering is arrow buttons driven by moveInOrder, which CLAMPS rather than wrapping — wrapping silently reorders a course the author did not mean to reorder, and the order IS the reading order. MEDIA comes from /hr/resources through a new LibraryPickerDialog, generalised out of MaterialPickerDialog by lifting its two hardcoded constants into props. assocType is the ORDINAL (13), never the enum id (15): the enum skips id 6, and sending an id lands on a different collection's rules. Alt text is required on any media slide and blocks publish — the authoring tool is the only enforcement point that actually holds, because a review checklist is run once and alt text is added never. PLAYER. Resumes at the first GAP, never the furthest point reached, and every gate unions the server's record with THIS session — reading only the server's row makes Next unreachable the moment a tick has not round-tripped, which is the exact bug the legal wizard shipped. Video completion is measured as WATCHED COVERAGE, not currentTime. /api/imaging/stream honours HTTP Range end to end, so a learner can seek to the last second in one request and any position-based gate is defeated by a single drag; accumulate() treats a jump larger than the tick gap as a seek and opens a new interval instead of crediting the span jumped over. videoCoverage.test.ts asserts "seek to the end yields under 5%" as its first case. Coverage is media time, so 2x playback still has to cover the timeline — which is why allowing it is fair rather than a loophole. A visible escape hatch appears once the video's full length has elapsed in wall-clock time and records skipReason on the progress row, because a gate with no escape becomes a support ticket whose answer is always "we'll mark it complete for you" — the same outcome, untracked. The test runner carries no correctness data at all: the payload is built from PlayerQuestionDto, which has no such field, and the server decides. Answers autosave so a closed laptop is not a lost attempt, and blanks are allowed but must be confirmed. npm test: 3 guards clean, 102 files, 1257 tests. next build compiles.

  6. · DaemonService

    The hourly training reminder tick

    HOURLY at :10, unlike the legal sweep's daily 13:00. Training reminds each org at its OWN local send hour, and Organization.timezone spans 21 hours (Pacific/Honolulu to Australia/Sydney) — a single daily UTC tick means "today" is wrong for most of the platform, which is exactly why the legal sweep tells a Sydney tenant "due today" on the wrong day. Each tick visits only the orgs whose local hour matches, typically one to three, so this scans LESS than a daily cross-org job rather than 24x more. The job lives here rather than as a @Scheduled in DocsService because this deployment is replicas: 1 with strategy: Recreate, so the old pod is gone before the new one starts and a deploy window cannot double-fire. DocsService is RollingUpdate with maxSurge: 1, so for the length of every rollout there are two pods and both believe it is 09:00 — and for a job whose output is mail to every overdue employee on the platform, "usually once" is not a property. Explicit finite timeouts, because a default RestTemplate waits forever and, combined with @DisallowConcurrentExecution, one wedged downstream stops the job running again until the pod restarts — silently, and indistinguishably from "nothing was due". Never rethrows at Quartz: an escaping exception may refire immediately, turning a skipped hour into a retry storm.

  7. · DocsService

    The complete HR Training backend

    Course authoring, publishing, assignment, the member player, server-side grading, WORM evidence and the reminder sweep — the whole module behind /api/docs/training. AUTHORING. Courses carry an ordered list of items where a TEST is a PEER of a UNIT, never a child: that one decision lets a single model express both "a test at the end of each training show" and "a final exam" without a second structure. Content saves are replace-set because the editor owns the whole ordered list; a per-row patch API needs a delete verb plus a reorder, which is three round trips and three chances for the outline the author sees to diverge from the one stored. PUBLISHING freezes an immutable version that assignments pin. Every rule a member is held to is copied onto it IN THE SAME STATEMENT that writes the row — the legal module declared those columns and left them to a later caller that never came, which is why every legal version in production carries a null attestation. TRFP1 is length-prefixed rather than delimiter-joined so a crafted value cannot imitate a field boundary, and isCorrect is INSIDE the hash: the legal module shipped LPFP1 without the equivalent field, publish answered "nothing has changed", and a corrected layout could never be frozen. Publish refuses with a 409 naming the offending item — including a media slide with no alt text, because the authoring tool is the only enforcement point that actually holds. GRADING IS SERVER-SIDE, and this is a security property rather than a feature. The player is served **************** which are different classes from the author's DTOs and simply have no correctness field to populate — a shared class with a nulled field comes back the first time somebody reuses a mapper, and the leak is silent while the compliance record keeps looking valid. Shuffle and the question draw are computed here and recorded on the attempt, so a resumed session sees the same questions; a client-side shuffle over a client-held answer key is theatre. Correct answers come back only when show-answers permits it for that attempt, so a member cannot fail deliberately to harvest the key. Partial credit is floored at zero (without it, selecting everything scores negative and drags down the rest of the test). Percent is rounded half-up exactly once so the results screen, the email, the certificate and the CSV cannot disagree about 79.5% against an 80% bar. A course with no tests passes on completion — possible == 0 must never divide and must never mean "failed". EVIDENCE. TRMF1 (what they saw) and TRQZ1 (what they answered) are stored as digests AND verbatim canonical strings, because a digest nobody can recompute from stored evidence is not evidence. The attestation is written for every terminal outcome, pass or fail: "they took it and did not pass" is exactly as much a compliance fact as passing. REMINDERS sweep ORG-MAJOR and fire each org at its OWN local hour. Organization.timezone spans 21 hours, so the legal sweep's ZoneId.systemDefault() tells a Sydney tenant "due today" on the wrong day. The bucket stays a pure function of (dueDate, today), which is what makes Extend Due Date free and the sweep replayable; catch-up sends only the most recent unsent rung after an outage and records the older ones as superseded. Idempotency is a real table with a unique claim index rather than a Redis SET NX, so "did we tell Ada on 4 March" survives a FLUSHDB and is queryable — the table legal's own javadoc says is right and deferred. A dry run claims NOTHING; claiming would burn every bucket it inspected and suppress the real run behind it. DocsInternalAuthFilter gains the training internal path. Without it the cross-org sweep is world-reachable, because ResourceServerConfig is anyRequest().permitAll() and OTKPreAuthFilter continues the chain when no OTK is present. 455 tests pass.

  8. · kamo-shared-library

    The four training email templates

    Same constant/dotted-key split as the HR legal family: the CONSTANT is the filename stem, the templateKey the platform SENDS is the dotted value inside each .json. Getting that wrong is the live QUOTE_SENT/commerce.quote_sent bug — every send 404s forever against a template that seeded perfectly — so HrTrainingTemplateTest pins both halves in both directions. ONE reminder template covers every rung of the ladder (T-14/7/3/1, due today, and every past-due period). Tone is carried by a pre-rendered eyebrow and duePhrase rather than by separate files, because a half-added canonical pair throws inside loadAll() — which is what seedForOrg iterates — and would break seeding for EVERY org on the platform rather than only its own. Each template is a 600px inline-styled card with a bulletproof VML + anchor CTA, because Outlook's Word engine ignores the anchor entirely. The test asserts ctaUrl appears exactly three times (VML href, anchor href, copy-paste fallback), that no template uses {{#if}} (the renderer is a flat regex replace and would render it as visible text), that no template pulls an external resource, and that each one carries the three variables EmailTemplateService injects so white-label branding works. Subjects use courseTitlePlain and bodies use courseTitle: the two render from ONE variable map, so an escaped value in a subject shows "Employee's Handbook" in the mail client's header while an unescaped one in a body ships live markup from the org's own DKIM-signed domain. No retake email is sent on a FINAL failure. Telling somebody by email that they have permanently failed a mandatory course, with no path forward and no human attached, is the wrong medium — the HR grid carries it instead. 1186 tests pass.

  9. · kamo-internal

    One member surface for both obligations, one badge for either

    The member's compliance tab had two sub-tabs, Pending and Completed, for one kind of obligation. Those are a STATE, not a kind — so they collapse into a status filter (with "all", which two tabs had nowhere to put, and which previously meant checking both to answer "was it this one I signed?"), and the tab strip is freed to separate the two things that are genuinely different: Legal Packages — instruments to sign, unchanged behaviour behind a filter Assigned Training — courses to take Each panel keeps its own filter in its own param. One shared ?filter= would carry a legal bucket into the training panel, where the same word means a different set of statuses, and switching tabs would silently re-filter the other list. A foreign ?sub= is normalised away on mount, because SettingsView.writeTabToUrl sets ?tab= and deletes ?configure but leaves ?sub= alone — the screen was already correct via the index fallback, but the URL was claiming a state this component never had, and the URL is what gets bookmarked and pasted into a support ticket. NavTop now shows ONE badge for BOTH. A member does not think of "packages to sign" and "courses to take" as two systems, they think of things they owe; two nearly identical marks side by side in fixed chrome would make them work out which one is lit. The count is the sum, and the icon gains a real attention state — two rings on ::before/::after of a wrapper, so the glyph itself never moves and never fights the Badge anchor. Motion is an ADDITIONAL signal, not the only one (the count and the error colour still carry it), which is why prefers-reduced-motion drops it with nothing lost. MemberAssignedTraining deliberately ships NO action column. trainingActionFor already decides start/resume/retake/certificate and is unit-tested, but the course player is not built — and a visibly disabled "Start" is a promise the product cannot keep, which reads as broken rather than unfinished. The column arrives with the player. trainingStatusesFor sends every status EXPLICITLY for "all" rather than omitting the param: the server's no-filter fallback exists to rescue a stale bookmark, not to be a calling convention. Statuses travel as repeated params, never CSV — a comma is double-encoded by the APIService gateway. check-i18n-keys caught the missing keys exactly as designed; hr.training.member.* was pushed to kamo-translation-dictionary first and verified on its origin/main before this commit. npm test: 3 guards clean, 98 files, 1221 tests passing.

  10. · DocsService

    The member-facing Training API

    Registers com.kamo.z.shared.hr.training in @EnableJpaRepositories — @EntityScan is broad so the entities are found either way, but repositories are not. Two authorization funnels, deliberately separate, mirroring the legal module: TrainingAccess gates /api/docs/training/** on MANAGE_TRAINING (RoleRightType 185) read AND write, which must stay exactly the right app/hr/hrTabs.ts gates the tab on. A server set narrower than the tab's leaves a member able to open the tab and collect a 403 over an empty grid; wider, and it is an endpoint reachable by somebody the UI never offered it to. Rights are read from member_rights_applied, never from the token, because the OTK session map carries no rights. TrainingAssignmentOwnership authorizes the member's own surface by IDENTITY. TrainingMemberController therefore holds no TrainingAccess field at all — a member taking a course they were assigned has no HR right, and routing them through the HR funnel would mean either locking every employee out of their own training or widening the HR right set until the catalog and audience roster open to the whole org. Ownership answers 404, never 403: a 403 confirms the uid is real and turns the endpoint into an oracle for who was assigned what, and an assessment record must not be enumerable by a peer. TrainingMemberSurfaceAuthTest pins all three properties by reflection, because the failure mode is somebody adding the field later "so the endpoint is secured too". GET /assignments and GET /assignments/pending-count. No mapping takes a member or org id — both come from the session. status is a REPEATABLE param, never CSV: APIService hands its upstream URL to RestTemplate as a String, which re-encodes %2C to %252C, so a comma arrives as one literal token matching no enum constant. An unrecognised filter means "no filter", never "match nothing", so a stale bookmark shows everything rather than an empty grid that reads as data loss. The paged read is a DocsService-local repository over the shared entity, sorted (deliveredAt DESC, uid DESC) — paging on a non-unique key duplicates and skips rows, and a 200-member assignment writes deliveredAt inside the same millisecond. pending-count returns the same JSON shape as the badge's pushed frame so one client parser serves both. Rows are mapped inside the transaction; course and version are LAZY. 413 tests pass.

  11. · InitializerService

    Create the HR Training schema

    Registers com.kamo.z.shared.hr.training in the explicit @EnableJpaRepositories list — @EntityScan is broad so the entities are found either way, but repositories are not, and without this the tables are never created. TrainingSchemaMigration (@Order(43); 42 is the legal one) adds what the entity mapping cannot express. Five UNIQUE indexes are treated as fatal because each is a correctness guarantee nothing else provides: - (course_uid, version_number): two concurrent publishes would otherwise mint the same number and every assignment pinned to "version 3" becomes ambiguous. - one attestation per assignment: the WORM guard blocks UPDATE and DELETE, not a duplicate INSERT. - the notification claim index IS the idempotency of the whole reminder pipeline — the claim is an INSERT against it, so without it a retried sweep re-mails every overdue employee of every tenant. - one recertification per (member, course, due date); one progress row per slide. The course-item CHECK is written as a two-column predicate on purpose: SchemaCleanupRunner @Order(1) drops every single-column IN-list CHECK on every run, because that is the shape Hibernate emits for @Enumerated(STRING). CockroachDB/Yugabyte have no ADD CONSTRAINT IF NOT EXISTS for CHECKs, so "already exists" is caught and treated as success. The test is reflection-only. mvn test in this repo MIGRATES PRODUCTION — DemoApplicationTests is a @SpringBootTest with no test profile and no src/test/resources — so it asserts the SQL constants against each entity's @Table name without starting a context, which is the failure that actually happens: a table rename leaving the hand-written SQL behind.

  12. · kamo-shared-library

    Stream document uploads and add the HR Training schema

    ImageService gains uploadDocumentStreaming, the streaming counterpart of uploadDocument. The buffered form cannot express a large upload at all: readInputStream allocates new byte[size], a Java array tops out at Integer.MAX_VALUE, and MinIOStorageService.upload then allocates a SECOND full copy for cross-node retry. Against a 2Gi container that made a few hundred megabytes the practical ceiling — which is why the imaging multipart cap sat at 500MB while the chat attachment path, which already streams, ran to 3 GiB. The new method hashes once (both digests off one buffer), sniffs the MIME type BEFORE the hash pass so a mislabelled 3 GiB file is rejected without spending a full read on it, and delegates storage to a new storeContentAddressed overload that accepts pre-computed digests so the source is read twice rather than three times. Deliberately not @Transactional: storing gigabytes takes minutes and a transaction held across it pins a pooled connection. Also adds com.kamo.z.shared.hr.training — 20 entities, 11 enums and 20 repositories for the HR Training module, mirroring com.kamo.z.shared.hr.legal's conventions: BaseUuidEntity, an explicit last_modified with touch() because BaseUuidEntity.dateUpdated is dead, pinned columnDefinition on every optional @JoinColumn, and @OnDelete(CASCADE) on the audience FKs because CascadeType.* emits nothing at DDL. Two departures from the legal blueprint, both deliberate: - TrainingAssignmentStatus splits FAILED into FAILED_RETAKE_ALLOWED (open) and FAILED_FINAL (closed), and REMINDABLE_STATES is strictly narrower than OPEN_STATES so a member awaiting grading is never chased. Pinned by TrainingAssignmentStatusTest. - hr_training_notification is a real table with a UNIQUE claim index rather than a Redis SET NX, so "did we tell them on this date" survives a FLUSHDB and is queryable. Answer and attestation repositories extend the bare Repository marker, not JpaRepository, so the batch delete methods that bypass @PreRemove do not exist on the interface. Requires a KamoInitializerService run before any service that reads these tables is deployed.

  13. · InitializerService

    Create billing_pay_groups explicitly, and undo the legacy collision

    Creating it in a migration rather than leaving it to ddl-auto, which produced a half-built table when the entity was pointed at the pre-existing billing_groups.

  14. · EmailService

    Size each mailbox against the organization's storage

    Dovecot now enforces a per-mailbox limit, and nothing was setting the column it reads. This writes it nightly, after the storage snapshot so the capacity being divided is today's figure rather than yesterday's. Each mailbox gets an equal share of the whole pool. The property that matters is that the shares sum to the capacity, so mail can never on its own take an organization past its ceiling. It is also the version an owner can predict — "your share of the organization's storage" — and it does not move when an unrelated domain grows, which giving each mailbox the remaining headroom would. Unlimited plans and organizations with no live subscription write 0, which the user_query turns into a NULL quota_rule: no limit. That direction is deliberate, because the alternative is refusing mail for an organization whose billing rows are missing or mid-migration.

  15. · KlusterServices

    Enforce storage quota on inbound delivery, and stop shipping the DB password

    Object storage already refuses content that would take an organization past its ceiling, which covers every domain going through the object store. Inbound mail was the gap: LMTP writes straight into the maildir with no Java in the path, so Dovecot's quota plugin is the only thing that can stop it. The plugin loads globally so LMTP delivery is checked — that is the path that matters — with imap_quota added for clients that ask. There is deliberately no default quota_rule: a global rule would apply to every mailbox the userdb does not answer for, which is the opposite of failing safe. The per-user rule from user_query is the only source of a limit, and it is NULL until quota_bytes is set, so every one of the 18 existing mailboxes is unlimited exactly as before. virtual_users.quota_bytes was added ahead of this, defaulting to 0, because the new user_query references it: shipping the config first would have failed every userdb lookup and taken mail auth down. The generated dovecot.conf was validated with doveconf against the live 2.3.20 before pushing; it resolves to "quota sieve" for LMTP and "quota imap_quota" for IMAP. Separately, the ConfigMap no longer carries the MySQL password. It shipped in plaintext, committed here and readable from `kubectl get cm -o yaml`. The ConfigMap now holds a placeholder and the credential comes from the existing mysql-secret through the environment; the container refuses to start if it is empty rather than running with an unusable userdb. Note this edits the -simple pair, which is what CI applies and what is actually running. dovecot-configmap.yaml and dovecot-deployment.yaml are applied by nothing and describe a different image entirely.

  16. · KlusterServices

    Enforce storage quota on inbound delivery, and stop shipping the DB password

    Object storage already refuses content that would take an organization past its ceiling, which covers every domain going through the object store. Inbound mail was the gap: LMTP writes straight into the maildir with no Java in the path, so Dovecot's quota plugin is the only thing that can stop it. The plugin loads globally so LMTP delivery is checked — that is the path that matters — with imap_quota added for clients that ask. There is deliberately no default quota_rule: a global rule would apply to every mailbox the userdb does not answer for, which is the opposite of failing safe. The per-user rule from user_query is the only source of a limit, and it is NULL until quota_bytes is set, so every one of the 18 existing mailboxes is unlimited exactly as before. virtual_users.quota_bytes was added ahead of this, defaulting to 0, because the new user_query references it: shipping the config first would have failed every userdb lookup and taken mail auth down. The generated dovecot.conf was validated with doveconf against the live 2.3.20 before pushing; it resolves to "quota sieve" for LMTP and "quota imap_quota" for IMAP. Separately, the ConfigMap no longer carries the MySQL password. It shipped in plaintext, committed here and readable from `kubectl get cm -o yaml`. The ConfigMap now holds a placeholder and the credential comes from the existing mysql-secret through the environment; the container refuses to start if it is empty rather than running with an unusable userdb. Note this edits the -simple pair, which is what CI applies and what is actually running. dovecot-configmap.yaml and dovecot-deployment.yaml are applied by nothing and describe a different image entirely.

  17. · kamo-internal

    Let a member license their own seat from the console

    The panel that closes the loop on self-pay. It appears only where the organization's policy permits it, and only for people who are not already buying on the organization's behalf — the server checks the same policy, so hiding it is a courtesy rather than the control. It says who gets billed before asking for a card, and under approval mode says plainly that nothing is charged until the owner agrees. Failing to read eligibility hides the option rather than showing one that would be refused. When the policy does not let self-payers choose a plan, they take the one the organization is on and the picker is not shown at all — an owner delegating cost is usually not delegating the product decision.

  18. · BillingService

    Tell people their payment failed, and stand the subscription down

    The dunning that did not exist. invoice.payment_failed wrote a row and returned: no email, no flag, no state change, and billing.email-service-url was configured and referenced by no Java code at all. The webhook now starts the delinquency clock on the first failure and clears it the moment an invoice is paid, so a recovered subscription begins fresh rather than carrying a stale clock into the next failure. Only the first failure starts it — a retry a week later must not restart grace and hand a non-paying subscription another week of service on every attempt. The sweep deliberately does not decide when entitlements stop. That happens on the clock, in isLive, so there is no window where a lapsed subscription still works because a job has not run. What the sweep owns is what a clock cannot do: saying so once, and cancelling after autoCancelAfterDays — thirty days by default, long after service stops. That gap is recoverable on purpose, because a customer who updates their card on day twenty gets everything back by paying, and a cancelled subscription is far harder to undo than a suspended one. Notification is best-effort by design: Stripe retries a webhook that answers non-2xx, and an unreachable mail service is not a reason to reprocess a payment event.

  19. · InitializerService

    Add the dunning clock columns

    past_due_since and suspension_notified_at, both nullable. Null means paying and not-yet-told respectively.

  20. · kamo-shared-library

    Make a failed payment mean something

    PAST_DUE counted as fully live in both entitlement resolvers, with no timestamp and no cap, so a customer whose card failed kept every feature for as long as Stripe left the subscription in that state — indefinitely in practice — and nobody, customer or operator, was told. A subscription now carries when collection first failed, and PAST_DUE is live only while inside its grace period. Computed on read, exactly like trial expiry, so service stops on the clock rather than waiting for a job to notice: a scheduled decision would leave a window where a lapsed subscription still worked because the sweep had not run yet. Two deliberate choices about being wrong. Grace defaults to a week rather than zero, because a failed payment is usually an expired card and not a decision to stop paying. And a past-due row with no stamp reads as inside grace, because every subscription predating the column has one — treating null as lapsed would have cut off customers the moment this shipped, for a delinquency nobody had measured.

  21. · BillingService

    Let a member buy their own seat, and close a body-parameter hole

    Self-pay had no route inside the console. Everything on the billing surface is account-shaped — you must already hold an AccountMember row before Plans & Billing shows you anything, and the only way to get one was an owner action — so a member who wanted to pay for themselves was told to go and ask. The one real self-pay path that existed, the consumer checkout used by white-label products, is unreachable from the console because the billing proxy rebuilds headers from scratch and never forwards the cookie its session resolver needs. The buyer is whoever the session says they are. X-Member-Id is set by the proxy from *** and never by the caller, so unlike the account routes there is no id in the path to swap — which is the whole reason this is its own route rather than another handler under /accounts/{accountUid}. The organization's policy is checked server-side, so hiding the option elsewhere is more than decoration, and the per-payer seat cap is enforced here rather than trusted from the client. Also closes a cross-tenant hole on POST /accounts. That route has no account id in its PATH, so AccountOrgScopeInterceptor — which keys on that segment — never saw it, and any member of any organization could name another organization's account in the BODY and rewrite its billing contact, redirecting its invoices and receipts. Naming an arbitrary memberId did the same from the other direction. Both are now required to belong to the caller's organization.

  22. · kamo-internal

    Let the owner decide who can pay, and manage the groups that do

    The owner had exactly one lever over self-payment and it was the wrong shape: an irreversible promote gesture, with no way to say in advance whether it was permitted and no way to undo it — while a separate consumer checkout path let a member self-subscribe regardless. This is the preventive control, and it is what the rest of the console hides itself on. Four modes, labelled by what happens to other people rather than by the enum: an owner picking a policy is deciding what their staff can see, so that is what each option describes. Groups only appear once the mode permits them, because a group is several people doing what one person may already do. Non-owners see the setting read-only rather than not at all. "The organization pays centrally" is the answer to why they have no purchasing options, and hiding it only turns that into a support question. A failure loading this section leaves the plan picker working — a policy the console cannot read should not take the buying surface down with it.

  23. · BillingService

    Org policy and billing group APIs

    Who inside an organization may pay Kamo is now answerable and settable. Both routes are org-scoped and therefore deliberately outside /api/billing/accounts/** and its interceptor: "may my staff buy their own seats" has to be answerable before a second account exists, which is exactly when an account-scoped setting cannot help. Reading the policy is open to any member, because the surfaces that hide self-pay controls need the answer and it reveals nothing actionable. Everything that changes who pays for what is owner-only, checked against the database rather than the caller-supplied header. Group creation refuses when the organization has not permitted groups — a group is several people doing what one person may already do, so an organization that has not allowed self-payment has not allowed this either. Under REQUIRES_APPROVAL a group starts unapproved; every other mode has already given blanket permission, so it starts approved. Deleting a group dissolves the naming and never the billing: cancelling what it pays for is a separate, deliberate act. The roster comes from the subscription's own SubscriptionMember rows rather than being duplicated on the group, so a group cannot disagree with the seats it is paying for.

  24. · kamo-internal

    A Plans & billing console an owner can actually find

    There was no way to buy a first subscription anywhere in the product. Plan lists were reachable only through a subscription you already held, so an organization with none saw an empty panel and a hint pointing at a different org. The one real billing screen lived on the member profile page, filed on the settings landing under a heading titled "Personal", and was hidden entirely for any org without a parent. The home administrator bar had no billing entry at all. The console answers the question an owner arrives with — what are we on, what does it cost, how do I change it — and prices every control as they move it, so the total is visible before anything is committed. Seats, add-ons and storage blocks each update a running figure, and an annual plan states what actually leaves the account once a year rather than leaving a per-month number to be multiplied in the reader's head. The arithmetic is planQuote, tested against the charge path, because both places the platform previously quoted a price disagreed with what Stripe billed. Reachable from the home admin bar and the settings menu, gated on the finance rights rather than CONFIGURE_SYSTEM: deciding what the organization pays for is a finance decision.

  25. · BillingService

    Default a new subscription's target org to the caller's

    A first purchase has no existing subscription to copy targetOrganizationId from, and requiring it unconditionally is what made creating one from the console impossible. An organization buying seats for itself is the common case; the sub-org arrangement still passes it explicitly.

  26. · kamo-internal

    Show the ceiling, not just the bytes

    The panel reported stored, added, removed and lifetime bytes against no reference at all — so an owner looking at 400 GB had no way to tell whether that was comfortable or about to refuse their next upload, and their first knowledge of a problem would have been a failed one. The plan allowance and the blocks bought are what turn those numbers into a position, so the capacity bar goes first and everything below reads against it. The bar stays green until it matters: amber at 90%, red at full. A bar that turns amber at 60% teaches people to ignore it. It also says plainly that reads are unaffected. "Storage is full" reads as "I have lost access to my files" unless you say otherwise, and the one thing filling your storage never does is take anything away. Block size and price come from the catalog rather than being hardcoded, so the "add 10 GB for $2.50 a month" line cannot drift from what is actually charged.

  27. · SecurityService

    Gate promotion on the owner's policy, make it idempotent, add its inverse

    Promoting a member to pay for themselves was unconditional, one-way and unsafe to repeat. It now refuses when the organization's policy says the organization pays for everyone — previously the only control over self-payment was the irreversibility of the gesture itself. It is idempotent. Promoting the same member twice inserted a second Account and a second AccountMember row, tripping the unique on member_id and answering 500; a double-click, a retry after a network blip, or an owner re-running the flow to move more licensees all hit it. It now reuses the account and subscription the member already has and carries on to the transfer. demoteBillingOwner is the inverse that never existed. An owner who promoted someone could not undo it, and if that member left, their personally-owned Account kept holding seats targeting the organization with no way to reclaim them. Licensees move back onto the organization's subscription and the self-payer's is stood down; their Account and payment methods are left alone, because reclaiming seats the organization depends on is the owner's business and deleting someone's billing history is not. Matching is on the shared User rather than the member id, since promotion mirrors the member onto the top-level org. Also fixes the endpoint ratchet, which was ALREADY FAILING on main before this work — so SecurityService's build was red and nothing was deploying. The scan could not see two real guards: extractUserId reads userID out of the *** session, and requireTopLevelOwner goes through CallerSessionResolver and further requires the caller own the top-level org. Teaching it both let 25 stale baseline entries be deleted, which is exactly what the file asks for — fixing an endpoint means removing its line, and that is what makes the number fall. runSweep is baselined with its real reason: internal-auth guarded through a header parameter the body-scan cannot reach.

  28. · InitializerService

    Drop the constraint that limited a member to one billing account

    Finds the constraint by shape rather than by name — Hibernate generated it and the name is not stable across environments, so any unique constraint on account_members whose column list is exactly member_id is the one to drop. The composite on (account_uid, member_id) has two columns and never matches.

  29. · kamo-shared-library

    Let members and groups pay for themselves, under the owner's rules

    Three things were missing and one was actively harmful. account_members carried a global unique on member_id — a member could administer exactly ONE billing account platform-wide. That is the ceiling that made every multi-payer arrangement impossible, and it was already doing damage rather than merely limiting: AccountMemberService.add() worked around it by silently DELETING the member's row on their previous account, so granting billing access to one revoked it from another with no warning; and promoteToBillingOwner threw a constraint violation on its second run, turning a double-click into a 500. The composite unique on (account_uid, member_id) already existed and expresses the real rule. Dropping it makes a derived findByMemberId returning Optional throw the moment anyone holds two rows, and nine callers rely on it. It is now a bounded query ordered by creation, so those callers keep the answer they had instead of depending on whatever the planner returns first; findAllByMemberId is there for code that wants all of them. OrgBillingPolicy is the preventive control the owner never had. The 2026-04-16 design specified billing_delegation_mode and implementation dropped it, leaving one irreversible promote gesture and no way to say in advance whether self-payment was permitted at all — while a separate consumer checkout path let members self-subscribe anyway. Where a mode forbids something the surfaces hide it rather than offering it and failing. BillingGroup names an arrangement the model already permitted: AccountSubscription is unique on (account, market, target org), so several accounts could always target one organization. It carries no roster of its own — who holds a seat is SubscriptionMember plus AccountLicense, the same answer as for anyone else, and a second roster would be a second truth to keep in step.

  30. · SecurityService

    Expose the fail-open impact to the operator

    The app catalog page already answers the org-independent question — does any plan mention this app — and raises a warning when none does. This answers the tenant-by-tenant one behind it, which is what actually gates flipping the valve.

உங்கள் உலகம் காத்திருக்கிறது.

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

எப்பொழுதும் இலவசம் — கடன் அட்டை தேவையில்லை