Releases: AshishGTH/openestate
Release list
v0.4.0 — Security fix + behavioral change: manager hierarchy visibility
Lead ownership and manager hierarchy — the second half of the v0.3.1
pilot-user triage's "big one," deferred out of that release for its own
design pass. Adds User.managerId and a proper reporting-subtree
visibility model, fixes a real security bug found while auditing the
existing scoping, and changes what non-admin roles can see.
Security
- A sales executive could read, create, and update follow-ups on any
colleague's inquiry, just by knowing its id — bypassing the
inquiry-level scoping entirely.FollowUpControlleronly ever
checkedcompanyId, never whether the caller could actually see the
parent inquiry. This is a live bug that has been shipping since
follow-ups were built, not something introduced by this release — it
surfaced during the audit for the manager-hierarchy work below. Fixed:
every follow-up read/create/update now confirms the parent inquiry is
in the caller's visible set first. If you have sales executives who
shouldn't see each other's pipelines, upgrade — this was open on every
prior version.
Changed — behavioral change for existing installs, read this before upgrading
- Before this release, every role except
sales_executive— including
sales_manager— saw the ENTIRE company's inquiries and reports.
Onlysales_executivewas restricted to their own queue; every other
role had no real scoping at all, just a blanket "see everything."
After this release, every role other thancompany_admin/
super_adminsees only their own reporting subtree, computed from
User.managerId. This is the correct behavior — it's the entire point
of this release — but it is a real, visible change: a user with no
managerIdset will see only their own leads after upgrading, even if
they used to see the whole company. Configure your org chart (set
each manager's reports'managerId) after upgrading, before your
managers ask where their team's leads went — otherwise this will look
like the release lost their data, not like a permissions model working
as intended.
Added
User.managerId— set a user's manager from the Add/Edit User
form. Drives who can see whose leads, applicants' inquiries, and
reports: a manager sees their own work plus their FULL reporting
subtree (not just direct reports), computed live on every request — no
caching, so a manager change takes effect immediately for whoever it
affects, no re-login needed. Cycle-safe: the form (and the API) reject
a manager assignment that would create a loop.- Every inquiry list/detail/update/assign endpoint, and both the
pre-sales and post-sales report modules, are now scoped by this
hierarchy via a newTeamScopeService, replacing three separate
hand-rolled scoping checks with one. - Manager-level users with zero reports configured now see an inline
hint on the Inquiries list ("Your team has no reports configured
yet...") pointing at Admin → Users, instead of a silent "No data
found" that's indistinguishable from actually having no leads. Closes
the exact confusion the behavioral change above would otherwise cause.
v0.3.1 — Security fix: credential leak in inquiry/follow-up endpoints
First real pilot-user feedback, triaged and fixed. Three critical bugs
(hit hourly by a working sales team), two "check before building" items
that turned out to be backend-complete, and a security leak found along
the way.
Security
- Credential leak, fixed: staff-facing inquiry and follow-up
endpoints returned password hashes, TOTP secrets, and recovery codes
to any authenticated staff user.GET /inquiries,GET /inquiries/:id, andGET /inquiries/:id/follow-upsembedded the
fullUserrow forassignedTo/createdBy— including
passwordHash,totpSecret, andrecoveryCodes— because a bare
Prismainclude: { relation: true }returns every scalar column on
the related model, not just the ones the UI displays. Any staff user
with read access to inquiries (not just admins) could see this data
in their browser's network tab on every list/detail/follow-up load.
Fixed by scoping both call sites (InquiryService.findAll/
findOne,FollowUpService.findAllForInquiry) to explicitselects
({id, name, email}/{id, name}), verified absent both in a
through-the-wire regression test and by inspecting raw response JSON
on a live deployed instance. If you are running v0.3.0 or earlier,
upgrade to v0.3.1 and rotate any staff password or TOTP secret you
consider exposed — anyone who had inquiry-read access and inspected
network traffic (or logs, if any client/proxy captured response
bodies) during that window could have captured these values.
Fixed
- A rep's own inquiry could silently reassign to admin.
InquiryService.create()
ran round-robin unconditionally whenever a project was set, with no
notion of who created the inquiry —Inquiryhad nocreatedById
column at all. New default policy: an interactively-created inquiry is
assigned straight to its creator; round-robin only runs for
machine-driven intake (inbound lead API, bulk import), which has no
human creator to retain ownership for. Configurable via
CompanyConfig.presalesCreatorRetainsLead(default on). - Editing a user, for any field, always failed.
UserForm.tsx's
edit-mode submit sent a create-shaped payload (includingemail) to a
.strict()update endpoint that never declared it — every save 400'd.
A deeper client-side bug sat in front of it: the form's validation
resolver was alwayscreateUserSchema, which requirespassword—
edit mode never renders that field, so submission failed silently at
the validation layer before a request was ever sent. Both fixed; a new
sharedpickForSchema()helper (projects onto an update schema's own
declared keys, rather than subtracting fields off a create-shaped
object) closes the class of bug, applied at all three known sites
(this one, Masters, Broker commission payment) plus a regression test
covering every create/update schema pair this package exports. - Direct URL access to an admin page rendered the full page shell for
any authenticated user, regardless of permission — the backend
correctly 403'd the underlying data fetch, but nothing distinguished
"no data yet" from "not allowed here." Every protected route now
renders an access-denied screen instead when the user lacks the
permission that gates it.
Added
- Bulk Excel inquiry import now has a staff UI. The backend
(row-level validation, applicant dedup,ExcelJS-based parsing) has
existed since Phase 3 with no caller inapps/web— upload UI, a
downloadable XLSX template (GET /inquiries/import-template, single
source of truth shared with the parser's own header map), and
per-row error reporting added to the Inquiries page. - Follow-up log now shows who logged it.
FollowUp.createdByIdhas
been captured since Phase 3 and was already being fetched — just never
rendered.
v0.3.0
See CHANGELOG.md for the full list.
Highlights
- Staff can now publish construction updates and photos to the customer portal. Backend and portal rendering have been complete since v0.2.2 — this release wires up the missing staff UI.
- You can now edit a project after creation (RERA number, address, location, etc.) —
codeis immutable, and changing location on a project with existing bookings shows a confirmation naming the booking count first. - Fixed: a booking's cost lines could silently price at 0% GST. The base cost line had no GST rate picker anywhere in the wizard. Now fails loud instead of defaulting silently, with a boot-time warning, an admin banner, and a "Zero-GST bookings" report for anything created before this fix.
- Fixed: a company's first open-ended GST rate blocked creating a second one. Now auto-closes the prior range instead of rejecting. The seed itself shipped this exact invalid state — fixed with real, verifiable historical GST rate dates.
- Fixed: clearing a GST/TDS rate's end date silently corrupted it to 1970-01-01 instead of clearing it, both in the API and the admin UI. Audited and fixed across every other optional date field in the schema.
- Fixed: upgrading an existing install left Super Admin unable to use newly-shipped features (v0.2.0's PLC/unit-charge pricing was unreachable on every upgraded install).
- Native installs: fixed a
git"dubious ownership" failure on the checkout the README's own documented install steps produce.
CI: all 6 jobs green (lint/typecheck/build, integration tests, Playwright E2E, Docker healthcheck, native install, native upgrade) — run.
v0.2.3 — custom field values (and a data-integrity fix)
Security
Custom field values are no longer sent to portal users. Until this release, GET /portal/profile returned the customer's whole applicant record — including any admin-defined custom field values — to the customer themselves and to their co-applicants. Custom fields are routinely used by staff for internal notes ("negotiation margin", "credit risk", "do not call before 11am"), and nothing in the field definition marks a field as safe to show a customer, so all of them are now withheld from every portal response. Per-field opt-in visibility is a deliberate future feature; withholding is the only defensible default in the meantime.
If your staff have written anything sensitive into a custom field on an Applicant, assume portal users could have seen it before upgrading. Values themselves are untouched — only what the portal returns has changed.
Fixed
Integrity fix: custom field values have accepted arbitrary, unvalidated data since Phase 3 — your database may already contain junk. customFields on Applicant and Inquiry was declared as "any object" at the API boundary (z.record(z.unknown())), so any caller could write any key with any value straight into storage: no type checking, no required-field checking, no check that a SELECT value was one of its options, and no rejection of keys that were never defined as custom fields at all. The validation code to prevent this had been written but was never called by anything.
Validation is now enforced server-side against your active field definitions on every write, for Applicant, Inquiry, Unit and Project. Unknown keys are rejected rather than silently discarded.
To see whether an existing install has junk values, run this against your database — it lists every stored key that has no matching custom field definition:
SELECT 'applicant' AS entity, a.id, k AS unknown_key, a.custom_fields -> k AS value
FROM applicants a, jsonb_object_keys(a.custom_fields) k
WHERE a.custom_fields IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM custom_field_definitions d
WHERE d.company_id = a.company_id
AND d.entity_type = 'APPLICANT' AND d.key = k)
UNION ALL
SELECT 'inquiry', i.id, k, i.custom_fields -> k
FROM inquiries i, jsonb_object_keys(i.custom_fields) k
WHERE i.custom_fields IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM custom_field_definitions d
WHERE d.company_id = i.company_id
AND d.entity_type = 'INQUIRY' AND d.key = k);Anything it returns is either junk written before the fix, or one of two expected system-written keys (leadNote from inbound leads, importNotes from CSV import). Values belonging to a field you merely deactivated will not appear — that definition still exists, so those values are accounted for.
Nothing is deleted by upgrading. Existing values are preserved and still shown on detail screens, marked "(inactive)" where no active definition covers them, and records carrying such keys stay editable — validation rejects unknown keys arriving from a client, but never retroactively invalidates what is already stored.
Deleting a custom field used to be a hard delete, which left its values orphaned in the database with nothing left to explain what they meant. Delete is now a deactivate (values preserved, field stops appearing on forms). Permanently destroying values is a separate action that requires typing the field's key to confirm, and records the affected row count to the audit log.
Added
- Custom field values now actually work. Admins could define custom fields since Phase 1, but nothing anywhere captured or displayed a value — defining a field had no effect elsewhere in the product. Values are now captured on real forms, validated, displayed on detail screens, and exported. Supported on Applicant, Inquiry, Unit and Project; a field's definition drives the form with no code change.
- New per-inquiry CSV export (
/reports/presales/inquiries-export) with one column per active custom field. BOOKINGis explicitly marked unsupported in the admin UI (with an explanation) and rejected by the API, rather than letting an admin define a field there that would silently do nothing. Seedocs/todo.md.
Upgrading
Native installs: deploy/native/upgrade-native.sh (adds a custom_fields JSONB column to units and projects; no data migration, no downtime beyond the normal restart).
CI
All 6 jobs green on commit 498de53 (run #105): lint/typecheck/build, integration tests, Playwright (12/12 scenarios), docker-compose healthcheck ({"status":"ok","db":"ok","redis":"ok","version":"0.2.3"}), native install, native upgrade from a populated previous-release DB.
v0.2.2 — project media: layout plans, brochures, construction photos
A note on this release's CI. This tag was cut during a major GitHub Actions platform outage, and its own CI run (#104) never completed — jobs failed to be acquired by any runner, with a workflow-level internal server error. The run was never re-attempted. The code in this tag is nevertheless covered by a fully green run:
498de53(v0.2.3) is a direct descendant of this commit, and its run passed all six jobs with real log evidence — Playwright 12/12, docker-compose healthcheck booting the tagged build, and a native upgrade from a populated previous-release database. This release is published after the fact so the releases page reflects what actually shipped, rather than skipping a version silently.
Added
- Layout plan / brochure / photo uploads.
UploadServicehas supported these categories since Phase 2 but no route ever called it — this release wires it up. Staff get a Media panel on a project's detail page (upload, list, download, delete); customers see the same files under a new "Layout plans & brochures" section on the portal Property page, with real download links. - Construction-progress photos actually render now. The upload path for
ConstructionUpdateMediahas existed since v0.2.1's own predecessor, but there was never a serving route on either side — the portal only ever showed a photo count. Staff and portal both gained a download route, and the portal Property page now renders the real images inline instead of a bare "N photo(s)" line. - Per-project storage cap. Layout plans, brochures, photos, and construction-progress photos all roll up disk usage under one project — unbounded uploads on a self-hosted box eventually fill the disk, which surfaces as Postgres refusing writes and looks like total system failure rather than a storage problem. A configurable file-count cap (default 50) and total-size cap (default 500MB) per project are enforced before any file touches disk, with a clear error naming the limit. Configurable via Company Config (
projectMediaMaxFiles/projectMediaMaxBytes). - IDOR coverage for both new download routes: a customer with a booking in one project cannot fetch another project's media by guessing an id — proven via the same raw-connection RLS discipline as every other portal IDOR test in this codebase, since both
project_media/construction_update_media's portal RLS predicates are multi-hop and therefore not mirrored at the JS layer. - Two new e2e Playwright scenarios: staff uploads a layout plan through the real UI and the customer downloads it in the portal; and a staff-published construction-progress photo renders as a real decoded image in the portal (not just a 200 response).
Still missing (at the time of this tag)
-
Custom field values. Admins could define custom fields (Applicant, Unit, etc.) via Admin → Custom Fields, but no form anywhere in either app captured or displayed a value for one.
This has since been delivered in v0.2.3, which also closed a data-integrity hole and a portal data-exposure issue in the same area. If you are installing fresh, go straight to v0.2.3.
Upgrading
Native installs: deploy/native/upgrade-native.sh. Superseded by v0.2.3 — upgrade to that instead unless you have a specific reason to pin this version.
v0.2.1 — staff ticket replies
Added
- Staff can now reply to and resolve customer support tickets.
Closes the loop that's been open since the portal shipped: customers
could raise a ticket and read replies, but no staff screen existed to
see or answer one — the backend (AdminTicketController) had been
fully built and idle the whole time. New Support section
(top-level nav, not nested under Post-sales — inquiries and brokers
raise tickets too, not just post-sales) with a queue and a thread
view: reply, change status, filter by status. - Ticket queue enrichment. The queue now shows who raised each
ticket (resolved from the linked Applicant/Broker, not a bare ID),
its category, how many messages it has, and when it was last active
— the minimum a triaging staff member needs to work a queue, none of
which the API returned before. - Overdue flagging from the existing
slaByAtfield. The column
has existed in the schema since Phase 6 with nothing reading it; the
queue now shows an "Overdue" flag when a ticket'sslaByAthas
passed and it isn't resolved/closed. No SLA policy or configuration
was added — this only renders a field that was already there. - The e2e harness gained its first scenario that drives both
apps/webandapps/portalagainst the same API in one test:
customer raises a ticket → staff sees it, replies, resolves it →
customer sees the reply. Every prior scenario exercised one app at a
time.
Still missing
Two gaps remain, tracked for future releases, not fixed here:
- Layout plan / brochure / photo uploads. The validation and
storage service (UploadService) has existed since Phase 2 and
already supports these categories, but no route in the Inventory
module has ever called it — there is no way, staff or customer
portal, to attach or view a project's layout plan today. - Custom field values. Admins can define custom fields (Applicant,
Unit, etc.) via Admin → Custom Fields, but no form anywhere in either
app captures or displays a value for one — defining a field has no
effect elsewhere in the product yet.
v0.2.0 — PLC & unit-charge pricing; GST and collection correctness fixes
Upgrade note: upgrade-native.sh now syncs new PERMISSIONS rows to
existing installs automatically (see the permission-delivery fix below),
but it does not — and should not — grant them to any role for you. After
upgrading, an admin must open Admin → Roles, edit each role that
should get the new PLC/charge-management capability, check
inventory.unit.plc-manage / inventory.unit.charge-manage, and save.
This is a deliberate, permissions-only sync — see the entry below for why
auto-granting them would be its own bug, not a fix.
Fixed
-
Correctness fix affecting GST charged on bookings and extra charges —
check your invoices if your company's GST State Code was ever unset.
isIntraStateSupply()used to silently default to intra-state
(CGST+SGST) whenever the company's GST state code or a booking's
place-of-supply state code was missing, instead of raising an error.
Any company whose Company Config GST fields were never filled in —
every install created before those columns existed, or any install
where nobody had visited Company Config yet — has been charging
CGST+SGST on every booking and extra charge regardless of where the
property actually is, which is the WRONG tax treatment (IGST) whenever
the real place of supply is in a different state from the company's
own. This produced no error and no warning; the only way to notice was
to already know the correct treatment and check by hand. If this
applies to you, review bookings/extra charges made while your GST
config was incomplete and correct any wrongly-taxed invoices — this
release does not retroactively fix already-issued invoices. Going
forward,isIntraStateSupply()now throws instead of guessing — a
booking or extra charge with incomplete GST config is rejected with a
clear error naming what to set in Company Config, rather than silently
taxed wrong. The app also now logs a warning at boot listing any
company with incomplete GST config, and the staff admin UI shows a
persistent banner linking to Company Config until it's completed. -
Correctness fix affecting reported collection figures — upgrade and
let the migration run before trusting any collection report. A prior
bug (see the REPORTS-phase entry inCLAUDE.md) left bounced-cheque
receipts still marked as collected (is_reversed = false) in every
collection report, rollup, and the customer portal's own payment
history — the code fix stops this going forward, but does nothing for
receipts that already bounced before you upgrade. Migration
20260804120000_backfill_bounced_receipt_is_reversedcorrects those
existing rows on your nextprisma migrate deploy(part of the normal
upgrade path — no manual step needed). It only touches the
is_reversed/reversal_reasonflag on affected receipts; it does not
alter any ledger entry, allocation, or installment. If your reported
collection totals looked too high before upgrading, they'll drop by
the sum of any previously-bounced cheques once this runs. -
A release that adds a
PERMISSIONSconstant never reached an
existing install — only a fresh one.seed.ts's permission-upsert
loop ran unconditionally, but the early-return gate right after it
(if (existingCompany) return) meant no other seeded content, and
critically no later permission addition, was ever re-applied to a
company created in an earlier release.upgrade-native.shnow runs a
dedicatedsync-permissions.tsstep (idempotent, permissions-table
only) on every upgrade, so new permission rows reach existing installs
the same way they reach fresh ones. Deliberately does NOT extend to
masters or roles — see the upgrade note above andCLAUDE.mdfor why
that's a different (and mostly correct-as-is) problem. -
A system role (
super_admin,company_admin, etc.) could never be
granted a newly-added permission through the UI, at all.
RolesService.update()rejected any change to a system role, not just
a rename — so even with the fix above delivering the permission row,
there was no way to actually grant it to a role. Scoped the guard to
an actual name change only; a system role's permission set is now
freely editable (its name and existence stay protected, as before).
Added
- PLC and unit-charge management. Unit-level PLCs (park-facing,
corner, etc.) and extra charges (IFMS, legal, etc.) can now be
assigned per unit from the Inventory → Project → Pricing panel, and
flow into a booking's cost breakup and the confirm step's total
automatically. PLC amounts are snapshotted in paise at assignment time
(from a percentage of the unit's rate, or a flat amount) and never
retroactively change if the base rate is revised later. - Per-charge-type GST rates. Charge Types now carry their own
optional GST rate and HSN/SAC code (Masters → Charge Types). A cost
line's GST resolves in order: its own rate, then its charge type's
rate, then the booking's base-line rate — never silently zero-rated.
A PLC line has no charge type, so it always inherits the base line's
rate; this is stated explicitly rather than left implicit.
Changed
- Replaced the
argon2password-hashing dependency with
@node-rs/argon2(pure Rust via napi-rs).argon2had caused two
separate deployment failures on two different platforms (an
Alpine/musl prebuild issue, and a still-unexplained SIGSEGV
crash-loop on GitHub'subuntu-latestCI runners — seeCLAUDE.md
for the full, ultimately-inconclusive investigation); the replacement
ships prebuilt binaries for every platform this project targets and
never falls back to compiling from source. Existing stored password
hashes remain verifiable — both libraries use the same standard PHC
string format, confirmed by directly cross-verifying a real hash
between the two before switching. No action needed on upgrade, no
password resets.
v0.1.2 — 2FA lockout and fresh-install fixes
Upgrade from v0.1.1 as soon as practical. Two bugs in that release
are severe enough on their own to justify this one:
- Any account with 2FA enabled was permanently locked out of login,
with no way back in — not even via a recovery code. The login
response's 2FA-pending branch never set the CSRF cookietotp/verify
requires, so everytotp/verifycall (including recovery-code
attempts) 403'd with "CSRF token mismatch," unconditionally, for
every account. If you're running v0.1.1 and have 2FA enabled on
any staff account, upgrade before enrolling further users — those
accounts have been unable to log in since enabling it, and existing
locked-out accounts will need a staff admin to disable 2FA for them
(POST /auth/totp/disable, or directly via the database) after
upgrading, since the fix doesn't retroactively unlock an in-progress
login attempt. - A genuinely fresh install failed immediately on the exact command
the docs tell you to run. Everydeploy/native/*.shscript an admin
runs directly was git-tracked without the executable bit;sudo ./install-native.shright aftergit clonedied at "Permission
denied" trying to execsetup-database.sh. Anyone who successfully
installed v0.1.1 did so from a checkout that had picked up a local,
uncommittedchmod +xsomewhere along the way (e.g. copying files
instead of cloning) — a literal fresh clone was never viable.
Fixed
- The 2FA lockout above also had a second, independent bug: the
code-verification schema only accepted 6 digits, rejecting every
recovery code'sXXXXX-XXXXXformat before it reached the
already-correct recovery-code check. Both fixed together; verified
live end-to-end (enroll, login-requires-code, wrong code rejected,
recovery code works once and is rejected on reuse). - Company config had no way to set
companyGstin/gstStateCodeafter
the initial seed, despite the frozen booking service already reading
gstStateCodeto decide CGST+SGST vs IGST. Added, with GSTIN format
validation (checksum digit deferred — seedocs/todo.md) and a
Company Config UI section. - Health endpoint's
versionfield was hardcoded0.1.0in every real
deployment (npm_package_versionis only set bypnpm run, never by
systemd's/Docker's directnode dist/main.js). Now reads
package.jsondirectly. - 18 of 19 generic master types (unit types, inquiry sources, charge
types, etc.) 500'd on any create/update call that included a
description —createMasterSchema's optionaldescriptionfield is
only backed by a real column onPaymentPlanTemplate; the shared
factory blindly spread the whole dto into Prisma'sdata. Fixed once,
at the root, in the factory. DocumentType,InterestRule, andTransferFeeRulecouldn't be
created via the API at all — each has its own required, non-nullable
columns (entityType;rateType/ratePercent/frequency;
feeType) that the generic master schema never had, so every attempt
500'd on a Prisma "Argument missing" error instead of failing
validation cleanly. The shared factory now supports a per-model
extraFieldsschema extension.LetterTemplatehad no working create path at all — zero templates
could ever exist, blocking demand/allotment/reminder letter generation
entirely. It was routed through the generic master factory (whose
schema has nosubject/entityType/body) instead of a dedicated
module; given one, mirroring the existingSmsTemplateModule
precedent, with merge-field validation at save time.- Duplicate master names (any of the 18+ shared-factory types) returned
a raw 500 instead of a clean "already exists" 400 — nothing in this
codebase had ever caught Prisma's P2002 unique-constraint error for
these dynamically-keyed services (unlikeRolesService/UsersService,
which pre-check viafindFirst). Mapped once, in the factory. install-native.sh/upgrade-native.sh's database migration step
failed on any host where the git checkout lives under a directory
tree thepostgresOS user can't traverse (e.g. GitHub Actions
runners:/home/runneris mode0750) — Prisma 6.19+ auto-discovers
aprisma.config.*file in the current working directory before
running any command, and that lookup'slstat()failsEACCES(not
ENOENT) in that case, which Prisma treats as a hard failure rather
than "no config file, proceed."run_as_superuser()now runs from
the already-world-traversable release directory instead of the
checkout.install-native.sh's finalsystemctl reload nginxfailed outright
("nginx.service is not active, cannot reload") on any host where apt
installed nginx without starting it — the script only ever checked
that thenginxbinary was present, never that the service was
running. Nowenables andreload-or-restarts it, correct whether
nginx was already running or not.CustomFieldDefinition.defaultValue— accepted by the create/update
schema since it was written, but no backing column ever existed, so
any real caller sending it 500'd. Added the missing column.POST /usersnever returned thephoneit had just saved — a
selectallowlist copy/paste gap (present inupdate(), missing
fromcreate()) left an admin with no way to confirm the phone
number was stored.
Everything above except the two headline bugs was found by a full
production-readiness pass and a new through-the-wire creation test for
every master type and admin-creatable entity (users, roles, custom
fields) — the existing suite seeded rows directly, which is exactly why
these bugs survived to a tagged release. Also added: real-HTTP creation
coverage for all 22 master types plus users/roles/custom fields, and a
native-install CI job that runs the full native install on a real
ubuntu-latest runner on every push. That CI job is not yet green — a
separate, CI-runner-specific issue (the deployed app crash-looping with
SIGSEGV, isolated to argon2's native module, confirmed not to reproduce
on a real server) is still open and tracked in CLAUDE.md; it does not
affect real installs, only that one job's own coverage.
v0.1.1 — native install path; Docker login hotfix
Added
- Native install path (
deploy/native/):install-native.shsets up
OpenEstate as a systemd service behind nginx, talking to a PostgreSQL
and Redis you already run — no Docker involved in production. Also
shipssetup-database.sh(standalone DB/role setup for a DBA-managed
Postgres),upgrade-native.sh(backup → build → migrate → cutover,
with automatic rollback on a failed healthcheck),backup-native.sh/
restore-native.sh, anduninstall.sh. Docker Compose is unaffected
and still fully supported — it's now positioned as a contributor tool
for the test suite rather than the primary production path; see
docs/docs/installation.mdandCONTRIBUTING.md. - A CI job (
native-installin.github/workflows/ci.yml) runs the full
native install on a realubuntu-latestrunner — installs PostgreSQL,
Redis, Node 20, nginx, and a build toolchain, runsinstall-native.sh,
and asserts the health endpoint responds and a login succeeds over real
HTTP — so the native path is verified on every push, not just once by
hand. - Consistent, user-visible toasts on failed mutations across both
apps/webandapps/portal(lib/toast.tsx+ aMutationCache-based
global handler in each app'smain.tsx), replacing a mix of silent
failures, inline-only banners, and unhandled promise rejections found
during an audit of ~15+ call sites.
Fixed
- Login failed on every fresh Docker Compose install with "Cannot POST
/api/api/v1/auth/login".VITE_API_URLdefaulted to/api
(deploy/.env.example,deploy/docker-compose.yml,
deploy/docker/{web,portal}.Dockerfile), butapps/web/apps/portal's
own API client already hardcodes/api/v1/...on top of it, and
nginx's/api/location already forwards that prefix through unchanged
— doubling it. Since Vite bakes this value into the built JS bundle at
image-build time, existing installs needgit pull+
docker compose up -d --build(not just a restart) to pick up the fix. - Every validation error (400) showed the generic toast "Validation
failed" instead of saying what was actually wrong, across both apps
— nestjs-zod's default exception hardcodes that message regardless of
which field failed. The API now returns the real per-field reason
(e.g."url: Invalid url; secret: String must contain at least 16 character(s)"). - A handful of mutations (webhook endpoint creation in
apps/web, three
non-mutation-hook webhook actions, PDF downloads in both apps) failed
silently or inline-only, with no toast — fixed as part of the same
audit. - Eight real bugs in the native-install scripts, found only by running
install-native.shend-to-end on a genuinely clean VM — none caught by
shellcheck,nginx -t, or a careful read of the scripts: a missing
build-toolchain prerequisite check (argon2needsmake/g++/
python3); a build failure silently swallowed by aset -e
propagation gap;NODE_ENV=productionleaking into the build and
dropping devDependencies; the build's own stdout corrupting a command
substitution;node <script>failing on pnpm's shell-script.bin
shims; a fragile cross-store Prisma client copy replaced with
regenerating the client in place;backup-native.shfailing under RLS
by dumping via the wrong role; andrestore-native.shfailing to drop
a database still held open by the running service. Full detail in
CLAUDE.md's decisions log.
Verified
- Ubuntu 24.04 LTS with PostgreSQL 16, and Ubuntu 25.10 with PostgreSQL
17 — both confirmed end-to-end: fresh install, safe re-install, real
HTTP login and role creation, upgrade with rollback, backup/restore,
and both uninstall modes. The scripts check forpsqlpresence, not
an exact major version, so this isn't expected to be an exhaustive
platform list — just what's actually been run.
v0.1.0 — first tagged release
The first tagged release of OpenEstate: a self-hostable, open-source
(AGPL-3.0) CRM for real estate, built for the Indian market first with a
plugin system designed for other verticals to adapt it without forking
core code.
Core domain
- Pre-sales: inquiry capture and dedup, round-robin/manual lead
assignment (fair under concurrency, advisory-lock serialized), follow-up
and site-visit tracking, escalation on overdue follow-ups, applicant
merge with a consent audit ledger, funnel/ageing/source reports. - Post-sales / financial core: append-only ledger (balances are always
computed, never stored), booking → payment plan → receipt → cheque
lifecycle, GST (CGST/SGST/IGST) and TDS (194-IA) handling, interest
accrual, unit transfer, cancellation with configurable deduction rules,
two-phase refunds, gap-free receipt/booking numbering, PDF generation
(receipts, statements, demand/allotment/reminder letters) with dispatch
and delivery tracking. Correctness proven by a property-based test suite
(fast-check) run against every CI build. - Brokers and commissions: slab-based commission rules, accrual with
point-in-time snapshotting, a request → approve → pay → reject payment
workflow, TDS (194-H) withholding, and clawback on booking cancellation. - Customer and broker portals: separate, minimal-scope authentication
and rate-limit buckets from staff; row-level security is the primary
isolation mechanism for these untrusted clients, not just a
defense-in-depth backstop. - Custom fields, terminology, and module flags: every entity supports
admin-defined custom fields; labels ("Unit" → "Product", etc.) and
enabled modules are configurable per company, not hard-coded — the
mechanism thegeneric-salesplugin (below) proves out for a
non-real-estate vertical.
Plugins, webhooks, and integrations
- A plugin SDK (
@openestate/plugin-sdk) with capability-gated context
access, package-boundary isolation (no database access path from
plugin code at all), SSRF-hardened outbound HTTP, and a first-party
generic-salesreference plugin. - Outbound webhooks: HMAC-signed, replay-protected, retried with
exponential backoff, auto-disabled after sustained failure. - Inbound lead API: per-key authentication and rate limits, a
generic field-mapping config so common vendor integrations (99acres,
MagicBricks, a generic webhook) need zero plugin code.
Security
- RBAC permission guards and independent Postgres row-level security
on every tenant-scoped table — not RLS as an afterthought. - Argon2id password hashing, TOTP 2FA, rotating refresh tokens with
reuse-family revocation, account lockout with exponential backoff. - PAN encryption at rest (AES-256-GCM, per-domain keys, never reused
across PAN/TOTP/plugin-secret rotation domains) for both brokers and
applicants; phone/PAN masking in list views and logs. - Immutable financial ledger enforced at the database level (a trigger
blocks UPDATE/DELETE, not just application discipline). - Redis-backed rate limiting shared across replicas and surviving
restarts. - Docker images pinned to verified sha256 digests; a randomly generated,
single-use initial admin credential (never a shared default). - A formal OWASP ASVS L2 self-assessment
and STRIDE threat model per
module, stating accepted residual risk plainly rather than implying
guarantees the architecture doesn't make.
Known limitations (see docs/todo.md for the full list)
- Rate-limit storage is now Redis-backed, but no other component of the
first-party plugin trust model provides worker-thread/process
isolation against a deliberately malicious plugin — first-party
plugins ship as reviewed npm workspace packages in this repository,
the same review bar as any other module, not as untrusted third-party
code. - No automated TLS/certificate setup in
install.sh— self-hosters
bring their own reverse-proxy TLS termination. - Some staff-service call sites still self-wrap tenant context
redundantly with the ambient interceptor (low-risk cleanup, a runtime
guardrail already backstops the one bug class this pattern caused
historically).