Skip to content
This repository was archived by the owner on Aug 7, 2026. It is now read-only.

Make the platform actually run: registry consumption, packaging, auth, and the dev loop - #143

Merged
kannan19302 merged 121 commits into
mainfrom
split/consume-registry
Aug 6, 2026
Merged

Make the platform actually run: registry consumption, packaging, auth, and the dev loop#143
kannan19302 merged 121 commits into
mainfrom
split/consume-registry

Conversation

@kannan19302

Copy link
Copy Markdown
Owner

What this is

Everything was green and nothing worked. pnpm verify reported 14/14 and the
document claimed "100% of Phases 0–5" at a point when the web app returned 500
on every route and the API rejected every authenticated request.

This branch makes the product run end to end and adds the test that would have
caught it.

Verified

  • pnpm smoke — register → log in → profile → authenticated pages → tenant data, over HTTP, no mocks
  • 120/120 API GET endpoints healthy
  • every web route 200
  • 5,031 API tests, 21 web tests
  • pnpm verify 14/14, nothing skipped

The defects

Packaging — three packages emitted ESM while declaring CommonJS. tsc put
"use strict" above "use client", silently turning all 44 client components
into server components. export * loses its names across the RSC boundary, so
ToastProvider arrived undefined. The design system shipped no CSS, then
shipped CSS its own output could not require; CSS modules are now resolved at
build time so the package loads in a bundler, in Node, or in vitest.

AuthJwtAuthGuard checked session revocation against a table with RLS
ENABLE + FORCE, before any tenant context existed, so it returned zero rows
and rejected every request while the session sat there active. Authentication
was impossible against a correctly-secured database. set_config(..., true) is
transaction-local, so setting the tenant GUC outside a transaction discarded it.
The IdP never put permissions into the token the API's RbacGuard reads.
/auth/me and refreshSession read a tenant relation the IdP schema does not
have.

Authorisation — nine controllers used TenantGuard with no JwtAuthGuard
to populate request.user, so they authenticated nobody. Now a HARD policy
rule, because this is the same shape as @Permissions without a guard.

Signup — a taken company name rejected the registration outright, which
locked out every other business sharing the name and confirmed to a stranger
whether a given company is on the platform. Slugs now take a suffix.

Dev looppnpm dev starts datastores in Docker and apps natively (§ 12:
the same compile is 962s containerised, 1s native), and refuses to report
success for a process it did not start.

Also

Restores docs/ai/CHANGELOG.md, which had been silently truncated to zero bytes.

Establishes docs/PLATFORM_ARCHITECTURE.md — the target platform architecture
derived from the measured state of the tree at v1.0.0, not from a template.

Topology: 15 repositories across 8 strictly ordered layers. A repository may
depend only on published artifacts of a strictly lower layer, which makes the
dependency graph acyclic by construction rather than by lint rule. Backend
deployables stay at 1: repository topology and runtime topology are decided
independently, and splitting the 45 business modules would put a distributed
transaction on the most correctness-critical paths in the system.

Also records the four mechanisms that replace what the monorepo provided for
free — signed release-train manifest, consumer-driven contract tests, automated
change choreography, and golden-path integration CI — and gates the extraction
programme on those mechanisms being proven inside the monorepo first.
main was red against its own committed baselines and could not be pushed:
explicit any 14931 -> 15483 (+552) and hardcoded hex 1519 -> 1625 (+106).
Fixed the cause in both cases rather than bumping a baseline.

explicit any: 15483 -> 14319. 1,164 occurrences of one scaffolded line
across 17 generated controllers -- ZodBody(z.object({ name: z.string() }))
body: any -- now carry the schema's inferred type, { name: string }.
zod-body.decorator.ts has always documented exactly this ("Pair with
z.infer<typeof Schema> for the parameter type so the validated shape and
the static type stay in sync"); the scaffold ignored its own convention.
Verified by inspection: 1,131 of the 1,133 call sites pass body straight
through to service params, and the remaining 2 read body.name.

hardcoded hex: 1625 -> 1321. Adds packages/ui-tokens/src/studio.css, a
design-tool chrome scale that sits alongside the existing charts.css
categorical palette for the same reason -- a builder canvas must stay
visually distinct from the content authored on it, so it needs its own
neutral scale rather than the application's semantic colours. 312 inline
hex literals across 12 builder components become var(--studio-*). The App
Studio, Web Studio, Workflow Designer, Form Builder and Dashboard Editor
now respond to the dark and high-contrast themes, which the inline values
made impossible.

Both baselines ratcheted downward and locked, per ARCHITECTURE_REVIEW R1
step 5 -- including @ts-nocheck 3241 -> 0, which had been achieved but
never captured, leaving the headline R1 win able to silently regress.
…math

apps/api now typechecks with 0 errors.

Removes 1,300 fabricated endpoints (3,922 lines) in
procurement-deep-expansion-bulk and inventory-deep-expansion-bulk.
Evidence they carry no capability: zero service or prisma references, no
constructor, and all 1,300 handlers return the identical static literal
{ status: "ok", feature: "<name>" }. Nothing outside their own module
registration referenced them.

They were 69% of the unguarded-route debt (1,889 -> 589). Adding
@permissions to 1,300 stubs would have moved the counter without
improving security; deleting them removes 1,300 authenticated HTTP
endpoints that served nothing. This is the same accretion pattern
recorded for the saas-deepening-* files in PLATFORM_ARCHITECTURE 1.3.

Fixes the 25 remaining type errors, all real Decimal/number defects:

- projects-enterprise.service.ts (24): earned-value management mixed
  Prisma Decimal currency terms with float arithmetic. Every currency
  term (BAC, PV, EV, AC, EAC, ETC, VAC, variances) now stays Decimal end
  to end; only the dimensionless indices (SPI, CPI, TCPI, percent
  complete) convert to number, at the reporting boundary. Also guards the
  TCPI denominator - when BAC equals AC the previous expression divided
  by zero and serialised Infinity into the API response.

- web-collections.service.ts (1): webOrder.total is Decimal(19,4) and was
  being summed as a float, losing cents across a large order book.

Policy baseline ratcheted down to 589.
Removes the saas-deepening-* and sales-deepening-* controller/service pairs:
74 files, 13,399 lines, 1,111 routes.

Evidence, applied per file rather than by name:

  - the service imports prisma and exposes it via a private `db` getter that
    is never called anywhere in the class;
  - every method either echoes its own arguments back with a hardcoded
    success status, e.g.

        async processApexCrownOp(tenantId, cmd, body) {
          return { tenantId, command: cmd, body,
                   status: "SAAS_APEX_CROWN_SUCCESS", timestamp: new Date() };
        }

    or returns an empty page, { data: [], count: 0 };
  - nothing outside its own module registration references the class.

On a billing, metering and tenant-provisioning surface, an endpoint that
returns SUCCESS without performing the operation is worse than a 501: no
caller can distinguish it from work that actually happened.

Scope was measured, not assumed. 37 of the 54 *-deepening / *-expansion
services matched every test above. The other 17 make real prisma model
calls (finance-expansion 2,557 lines, ai-expansion 40 calls,
builder-expansion 38, communication-expansion 33, ...) and are untouched.

apps/api typechecks at 0 errors after the removal. Combined with the
earlier bulk-controller removal this retires 2,411 fabricated endpoints
and 17,321 lines.

Names carrying no domain concept -- apex, crown, seal, pinnacle, quantum,
infinity -- were the signal that led here, as recorded in
docs/PLATFORM_ARCHITECTURE.md section 1.3.
CRITICAL. Confirmed cross-tenant privilege escalation, closed.

The chain, each link verified in the tree:

1. Every tenant's first user is seeded with the SUPER_ADMIN role carrying
   permissions: ["*"] - apps/api/src/modules/auth/auth.service.ts, the
   registration flow's defaultRolesConfig.

2. hasPermission() returned true for a bare "*" against ANY required
   permission - packages/shared/src/utils/index.ts.

3. SuperAdminController is @SkipTenantScope() and states in its own header
   comment: "Deliberately cross-tenant: this controller aggregates data
   across every tenant for the platform operator (e.g. prisma.user.count()
   platform-wide)."

4. It is gated by @permissions("system.tenant.read") - which ["*"] satisfied.

Any customer's own administrator could therefore call GET /super-admin/tenants
and enumerate every tenant on the platform, read tenant detail, list all
admins, read platform analytics and system health, and reach provisionTenant
and updateTenant on the same controller.

That no seeded role grants system.* explicitly is what makes this conclusive:
the tenant wildcard was not one of several paths to those endpoints, it was
the only one.

Fix: "system" and "platform" are reserved control-plane namespaces. A
control-plane permission is satisfied only by a grant that is itself inside a
control-plane namespace - an exact code, or a wildcard such as system.* or
platform.tenant.*. A tenant-scoped grant can never cross the boundary. "*"
means everything in MY tenant, never everything on the platform.

Six regression tests cover the escalation path, the legitimate control-plane
grants, and the same-prefix edge case (a tenant module named "systems" must
keep behaving normally).

The control plane is now fail-closed: unreachable until platform-staff roles
carrying explicit system.* grants are provisioned outside tenant role seeding.
That is the correct posture and it is the substance of Phase 1 in
docs/PLATFORM_ARCHITECTURE.md - this was the risk section 1.2 described as
"one authorization bug away", and it was not theoretical.
… in CI

The two-tenant RLS suite failed with PrismaClientInitializationError whenever no database was reachable, so pnpm verify went red for an environmental reason. That trains people to ignore a red run, which is the failure mode this repo can least afford.

It now probes the connection at module scope and skips declaratively via describe.skipIf. In CI it throws instead: this suite is the only mechanical proof of tenant isolation, so it must never silently vanish from the pipeline. Mirrors verify.mjs, which already marks the RLS gate optional locally and hard in CI.
A second, independent cross-tenant escalation, and the guard that makes the
class of bug unreachable rather than fixing one instance of it.

TenantLifecycleController is @SkipTenantScope() and every route takes a
tenantId straight from the URL and acts on that tenant. It was guarded by
admin.tenant.export / suspend / unsuspend / offboard / purge.

`admin.*` is a TENANT namespace, and the seeded ADMIN role carries exactly
that grant (auth.service.ts defaultRolesConfig). So any customer's ordinary
admin - not even their super admin - could suspend, fully export, offboard or
purge any other tenant on the platform by id. `export` is the worst of them:
a complete data export of an arbitrary tenant.

The previous commit's hasPermission fix did NOT close this, because admin.* is
not a control-plane namespace. That is precisely why a second layer is needed:
one wrong permission string was enough to reopen the hole.

Two changes:

1. Rescope. The six lifecycle permissions become system.tenant.*, registered in
   the permission registry and documented as never-seed-to-a-tenant-role. The
   reserved namespace introduced in the previous commit means no tenant-scoped
   wildcard can reach them.

2. ControlPlaneGuard, applied to all three @SkipTenantScope() controllers
   (SuperAdmin, TenantLifecycle, Operations). For any cross-tenant handler it:
     - fails closed when the handler declares no permissions at all, instead of
       serving platform-wide data;
     - rejects a cross-tenant handler guarded by a tenant-scoped code - which is
       exactly the defect above, now unrepresentable rather than merely fixed;
     - audit-logs both grant and denial, since a denial here is a probe.

@SkipTenantScope's own docstring says such a handler "is responsible for its
own access control". That responsibility was previously discharged by a single
permission string. It is now a guard that states the requirement positively
instead of inferring it from the absence of a denial.

Layer three is the separate origin, realm and ingress in Phase 1; layer four is
the repository split, after which tenant-plane code cannot link against
control-plane handlers at all. See docs/PLATFORM_ARCHITECTURE.md sections 1.2
and 3.1.

Also fixes apps/web registeredModules, which registered six modules twice
(search, drive, reporting, localization, subscriptions, fixed-assets) - 29
registry entries for 23 modules. Caught by registered-modules.test.ts, which
maintains its expected list by hand precisely so it cannot go vacuous.
Two HARD gates, zero tolerance:

control-plane-seeded-to-tenant scans role 'permissions: [...]' blocks in seed.ts and auth.service.ts and rejects any system.*/platform.* grant. cross-tenant-tenant-scoped-permission scans every @SkipTenantScope() controller route and rejects any @permissions code outside a control-plane namespace.

Both were verified to actually fail rather than assumed to: reintroducing the exact pre-fix defects turns each red with file:line, and the tree was then restored. A gate that cannot fail is worse than no gate, which is the finding this repository already recorded as F2.
Adds a status table to section 14 covering what moved from v1.0.0, what is still ratcheting, and what is blocked. Two items - RLS coverage verification and the Float-to-Decimal migrations - are blocked on a running PostgreSQL rather than on any decision, and are marked as such rather than as done.
…roll

50 files, 4,790 lines, 456 routes across 25 controller/service pairs in
hr-advanced, crm and inventory.

Same evidence standard as the first batch, applied per pair rather than by
name:
  - the paired service makes zero prisma model calls;
  - every method body is a single static object return;
  - nothing outside its own module registration references either class.

19 candidates failed one of those tests and were left alone, including
crm-customer-success-deep.service.ts, which a sibling service references, and
several *-generated.service.ts files that have real method bodies.

This batch matters more than the first. hr-payroll-deep.service.ts is a
hundred lines of:

    async createPayrollRun(..._args: any[]) {
      return { status: "ok", method: "createPayrollRun" };
    }

alongside getPayrollRuns, assignSalaryStructure, createSalaryComponent and
getEmployeeSalaryStructure. An operator posting to /hr/payroll-deep/pay_7
receives a success response and has every reason to believe payroll ran. This
repository's own CLAUDE.md opens with "Someone's payroll runs on what you
ship."

Counters, all ratcheted down and locked:
  unguarded routes   589 -> 407
  explicit any     14,319 -> 12,493
  apps/api typecheck    0 errors, unchanged

Cumulative across both batches: 2,867 fabricated endpoints and 22,111 lines
removed.
Audit of the platform-split work. Four findings, all fixed.

1. The IdP client was a mock, wired into production.

   apps/api/src/common/idp-client.ts returned literals from every delegate —
   findMany → [], findUnique → { id: "mock-id" }, count → 0 — and was cast
   `as any`. 844 files and 1,096 call sites imported it, including
   admin.service.ts, people.service.ts, and the control plane's own
   super-admin.service.ts and tenant-lifecycle.service.ts. All of them read
   empty identity data and silently discarded writes, while typechecking green.

   A real generated Prisma client for the IdP schema already existed at
   packages/database/src/idp-client and is exported as `idpPrisma`. This routes
   to it. UserPresence and UserStatusSchedule stay on the main client because
   they are declared in prisma/schema/core.prisma, not the IdP schema — a
   wholesale re-export would have replaced a silent mock with a runtime crash
   on 13 call sites.

2. @ts-nocheck was hiding real data corruption, not type noise.

   Four core files had @ts-nocheck re-added with "RATCHET DEBT" headers and
   exit conditions. Removing them surfaced 31 errors caused by a mass
   "add as any" pass that had replaced real expressions with literals:

     - setPresence / createStatusSchedule wrote presence, visibility,
       statusText and statusEmoji as "" rather than the submitted DTO;
     - the Connect directory reported designation "" and department null for
       every employee — `e.designation` had become `"" as any` and
       `e.departmentId` had become `"" as anyId`, which does not even parse;
     - avatar was `"" as any` though the query selects it;
     - channel starred/muted were hardcoded false, ignoring saved preferences;
     - CommunicationService carried `[key: string]: any;` twice in its class
       declaration.

   The surviving fragments (dto.clearAt, the `dto.visibility ?` guard, the
   select lists) are what identified the originals.

3. Five scripts were broken by the R2 schema split.

   check-schema-lints, check-pii-registry, classify-floats,
   generate-float-migration and report-migration-reconciliation all opened
   prisma/schema.prisma and threw ENOENT once it became prisma/schema/*.prisma.
   schema:lint is a CI gate, so it was not failing — it was crashing, which
   reads as tooling breakage and gets ignored. Added scripts/lib/read-schema.mjs
   and taught classify-floats to rewrite the correct per-model file.

4. The suppression ratchet had a hole the split walked through.

   SCAN_ROOTS was four hard-coded paths, so apps/idp, apps/console,
   apps/developer and apps/extensions were never scanned — ~480 `any` and four
   @ts-nocheck files, one per extension entrypoint, sat outside the gate while
   it reported green. Scan roots are now the pnpm workspace roots. Baseline
   re-established once over the wider scope, recorded as ADR-006.

Result: apps/api typechecks at 0 errors, @ts-nocheck at 0 across the wider
scope, explicit `any` down 63, and all four fast gates green.
Deletions only. The saas/* console pages move to apps/console and the monolithic schema.prisma is replaced by prisma/schema/*.prisma (R2 / ARCHITECTURE_REVIEW F4).
Guards, interceptors, middleware and shared services updated for the split: identity reads go through the IdP client, and RbacGuard/JwtAuthGuard take permissions from signed JWT claims instead of a per-request database hop.
claude added 3 commits August 6, 2026 05:33
Registration failed with "An organization with a similar name already exists"
whenever the derived slug collided. Two problems with that.

Company names are not globally unique. There are many businesses called Acme,
and the first tenant to register one locked out every other customer who shares
it — a real customer blocked by an unrelated stranger's choice.

Worse, the message answered a question the caller has no right to ask. It
confirmed whether a given company is on the platform, which turns the public
signup form into a tenant-enumeration oracle for anyone holding a list of
company names. That is a cross-tenant disclosure through the one endpoint that
must be open to strangers.

The slug is a URL and must be unique, so a collision now takes a numeric suffix.
The display name is stored exactly as typed and is not constrained at all.

Found by the smoke journey: it reuses one organisation name every run, so the
second run failed. A defect that only appears on the second signup is precisely
what a one-shot manual test misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pnpm dev` brings up the datastores in Docker and the applications natively,
waits until each genuinely answers, and prints where everything is. `pnpm smoke`
then walks a real journey against it.

Three things this fixes.

The previous version was a .ps1 invoked through `pwsh`, which is not installed
on a stock Windows machine — the script whose entire purpose is to make starting
the stack easy failed at "pwsh: command not found". It is Node now.

Applications run natively rather than in containers. That is § 12's model and it
is not a preference: the same Next.js middleware compile took 962 seconds in a
bind-mounted container and 1 second natively. `--docker` still runs everything
in containers for CI parity.

And it refuses to report success for a process it did not start. A port already
held by something outside the stack makes `docker compose up` fail for that
service while the health probe still passes, because something IS answering —
so it printed a tick for a container that never started. It now names the
conflicting port and exits.

Also ignores .pnpm-store/, which a local install created inside the repository
and which `git add -A` duly tried to stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Superseded by scripts/dev.mjs. It required pwsh, which is not present on a stock Windows install, so it never ran here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation ci dependencies Pull requests that update a dependency file frontend Next.js web app backend NestJS API database Prisma / PostgreSQL schema or migrations ui @unerp/ui-* design system packages labels Aug 6, 2026
The applications are children of this process, so returning after printing the summary killed them — the script announced three ticks and left nothing running, which is the most confusing possible outcome. It now holds the event loop and shuts the children down on Ctrl-C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce283d74ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .npmrc Outdated
Comment thread .npmrc Outdated
Comment thread apps/api/src/platform/platform.module.ts
Comment thread scripts/dev.mjs Outdated
Comment thread apps/api/src/common/guards/control-plane.guard.ts Outdated
Comment thread pnpm-workspace.yaml

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

claude added 7 commits August 6, 2026 08:11
Its postinstall no longer fails the whole install when prisma generate cannot complete — that made @unerp/auth uninstallable, because npm's cleanup left a half-removed directory that broke every subsequent attempt. It warns and tells you to run prisma generate instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registration failed silently in a real browser while every API-level test
passed, because the two exercised different paths.

`API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api/v1"` looks harmless. Set
that variable to a bare origin — which the dev script did — and the base becomes
`http://localhost:3001`, so the browser requests `http://localhost:3001/auth/me`:
no `/api/v1` prefix, and aimed straight at the business API, bypassing the web
origin entirely. That skips the rewrite in next.config.mjs which routes
`/api/v1/auth/*` to the IdP, so authentication was being sent to the one service
that does not own it. Both requests 404'd.

The relative default is the correct answer, and the dev script no longer
overrides it. An absolute value is still honoured for genuinely cross-origin
deployments, but the path is now appended rather than dropped.

Found by driving the actual browser. The smoke journey calls
`http://localhost:3000/api/v1/auth/register` directly and passed throughout —
it was testing the proxy path that the browser never took. A test that exercises
a different route than the product is a test that agrees with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bundled

Driving the app in a real browser found what HTTP probes could not.

The design system must be TRANSPILED, not externalised. It ships React
components, and an external package is `require()`d at runtime with its own
React resolution — so the server graph and the client graph end up with two
copies and every hook dies on `Cannot read properties of null (reading
'useState')`. It could not be transpiled while its compiled output `require()`d
CSS modules, which is why it was external in the first place. Resolving CSS
modules at build time removed that constraint, so the correct config is finally
available: webpack owns the package, there is one React, and the CSS is real.

React is pinned to 19.1.0. Next 15.3.4 bundles a react-server-dom-webpack built
against 19.0/19.1; 19.2 moved `registerClientReference`, and an unpinned install
had quietly drifted forward.

Verified in a browser, not by curl: registration wizard through all three steps,
tenant provisioned, apps chosen, and the Global Enterprise Dashboard rendering
with its KPI tiles, sidebar and tenant switcher — fully styled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Next 15.3.4 bundles a react-server-dom-webpack built against React 19.0/19.1. An unpinned install had drifted to 19.2, which relocated registerClientReference and broke every server-rendered route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a fail-open

Six findings from review, all real.

SECURITY
- .npmrc committed a registry `_authToken`. Anyone with repository access could
  reuse it, and it was copied into every clone and container image. Removed. The
  token must be treated as compromised and rotated; secrets belong in the
  environment, never in a tracked file.
- ControlPlaneGuard's MFA check sat inside
  `if (user.amr !== undefined || user.mfaVerified !== undefined)`, so a token
  carrying NEITHER claim skipped it entirely and fell through to the permission
  test. The comment above promised the opposite — "a session with no MFA claim
  at all is refused". A legacy or password-only platform session could reach a
  cross-tenant handler. Every existing test supplied one claim or the other, so
  nothing exercised the gap; the new test does, and fails against the old
  condition.

CORRECTNESS
- PlatformModule was declared and never imported, so every /api/platform/v1/*
  route — tenant lifecycle and provisioning included — did not exist at runtime
  while its unit tests passed by instantiating services directly. A module
  nobody imports is dead code that looks live.

BUILD
- The registry cutover is reverted: packages/* is a workspace member again.
  Pointing the @unerp scope at http://localhost:4873 made every
  `pnpm install --frozen-lockfile` on a runner resolve against the runner's own
  localhost, and left db:generate/db:deploy/db:seed matching no project, because
  a pnpm filter selects workspace projects and not installed dependencies. § 14
  requires the monorepo stay buildable until consumers have switched, and CI is
  the proof — so the switch cannot land before the registry is reachable there.
- Five gates had hardcoded themselves to node_modules/@unerp/database during
  that cutover and threw ENOENT the moment the package moved back. They now
  resolve whichever location exists, and still fail loudly if neither does.
- Replaced a console.error in the restored blockchain event listener with an
  injectable error sink. Console output bypasses the structured, tenant-labelled
  logging § 11 requires, and swallowing the failure would let a dead event
  stream drift the ledger out of sync unnoticed.

The browser base-URL finding was already fixed in 7fb1f23.

pnpm verify: 14/14 green, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness compares the committed file byte-for-byte against what it would write now. Prettier reformatting it on every commit guaranteed a permanent 'published expectation is stale' failure that re-recording could never settle — the gate and the formatter were fighting over a generated artifact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
git's text=auto handed Windows a CRLF copy of a file the harness writes with LF and then compares byte-for-byte, so the gate reported drift that no re-recording could settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

kannan19302 and others added 3 commits August 6, 2026 19:06
`pnpm dev` brought up Postgres, Redis, MinIO, the registry and the web app, and
the API exited during module load:

    ERR_MODULE_NOT_FOUND  packages/extension-api/dist/capabilities

`packages/extension-api/src/index.ts` re-exported `./capabilities`, `./schema`
and `./bundle` without file extensions, and `packages/contracts/src/index.ts`
did the same for its three sub-indexes. Node's ESM loader treats a relative
specifier as a URL — it appends nothing and tries no directory index — so
`./capabilities` means a file with no extension, which does not exist.

Why fourteen green gates could not see it, which is the part worth keeping: the
root tsconfig sets `moduleResolution: "bundler"`, so the compiler was explicitly
told to assume extensionless specifiers resolve, and it emits them unchanged.
The unit suites resolve through Vite. Every gate in the pipeline reads this code
through a bundler, and the only consumer that does not is the runtime. Neither
package declares `"type": "module"`, so what selects the failing loader is
Node's ESM syntax detection — the emitted `import` keyword is enough.
`@unerp/shared` and `@unerp/auth` declare the type and write `.js` specifiers,
and are the two that have never broken this way.

Fixed with six `.js` specifiers. Verified by boot: API answers
`/api/v1/health` 200, IdP answers, web serves, and `pnpm smoke` walks the full
journey 18/18 — register, log in, a token carrying claims, profile, five
authenticated web routes, four API routes, IdP.

A fix without a gate is a fix that recurs, so this adds gate 15,
`scripts/ci/check-node-resolution.mjs`. It reads the emitted `dist/` — the
artifact that actually loads — and checks every relative specifier against
Node's resolver rather than the compiler's: verbatim under ESM, extension and
index search under CommonJS. The package set is derived rather than listed —
every `@unerp/*` reachable through `dependencies` from `apps/api` or `apps/idp`,
transitively — because a hand-maintained list goes stale exactly when a package
gains a dependency, which is when the gate is needed. `@unerp/ui` and
`@unerp/framework` are exempt: Next.js bundles them and extensionless specifiers
are correct there, and enforcing a rule their runtime does not have would be
false precision. Generated Prisma output and compiled test files are excluded on
stated grounds rather than swept.

Proven able to fail — revert one specifier and it exits 1 naming the file and
the fix; restore it and it exits 0 — which is the standard § 14 applies to every
other gate here. Wired into `pnpm verify` after Build, and into CI as a sixth
`static` matrix leg, since CI runs the checks individually rather than through
verify.

It immediately found six more instances of the same defect, already latent:
`@unerp/shared`'s `field-service/index.js` and `real-estate/index.js` each
re-export three schemas without extensions. Nothing imports those subpaths yet,
so nothing had broken; the first consumer would have hit this exact failure.
Fixed in the same pass.

Records the whole thing as PLATFORM_ARCHITECTURE.md § 14.1 alongside the
container findings, and amends § 14's status table — the "end-to-end product:
verified" row was true when written and had regressed by the next day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Static (format)` has been red on this PR. `pnpm format:check` on a Linux runner
found 202 files, mostly `apps/web`, the Playwright journeys and the k6 scenarios.
Formatting only, no behaviour change.

Also deletes `check.js` — three lines at the repository root that read the
feature ledger and `console.log` its line count. Nothing references it. It is
the "no one-off scripts, temp files, or debug artifacts left behind" rule in
CLAUDE.md, and it surfaced only because prettier touched it and lint-staged then
failed the commit on its `no-console`. Suppressing that would have been the
wrong fix twice over.

Worth recording how this looked from a Windows checkout, because the obvious fix
is a trap: `core.autocrlf` is true and `.gitattributes` sets `* text=auto`, so
the working tree is CRLF while prettier's `endOfLine` is `lf`. `pnpm format:check`
therefore reports **3,016** files locally against CI's 202, and `pnpm format`
rewrites every one of them. Committing that produces three thousand files of
pure line-ending churn with the 202 real changes buried inside.

Git already knows the difference: staging normalises CRLF to LF, so `git add -A`
after the format leaves exactly the 202 files whose content actually changed.
That is what is committed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pnpm verify` was green through all of them, which is the finding underneath the
fixes: **CI runs gates `verify` does not have.** `CLAUDE.md` tells every
contributor to run `verify` before pushing, so it tells them the change is clean
and CI then fails on something they had no local way to check.

M3 Choreography Sync — `setup-node` with `cache: pnpm` ran *before* pnpm was
installed, so the job died at setup and had **never executed its own body**. That
hid two more bugs inside it: it invoked `scripts/ci/check-module-boundaries.mjs`,
which is not where that file lives, and it invoked the boundary checker from the
repository root, where the checker resolves its baseline relative to the working
directory and exits 1 on a clean tree. A job that fails in setup is
indistinguishable from a job that works, in every way except the badge — and M3
is one of the four mechanisms § 4.5 calls non-optional and ADR-011 declares the
split void without.

Static (contracts) — the expectations are stale on a Linux runner and clean on a
Windows checkout, and the gate reported only the word "stale". A drift that does
not reproduce locally cannot be diagnosed by re-running `--record`, because
locally there is nothing to see. It now names the providers and symbols that
differ, so the CI log is the diagnosis rather than the start of one.

Supply chain — 78 vulnerabilities → 39, high 39 → 21, critical 2 → 1. `next` was
pinned at exactly `15.3.4` in `apps/console` and `apps/developer` against a React
flight-protocol RCE patched in `15.3.6`, and `apps/web` carried a range that
permitted a fixed version yet resolved to the vulnerable one anyway, because a
wide optional peer in `@unerp/ui` held the old resolution in the lockfile. A
workspace-wide `pnpm.overrides` entry is what actually moved it: with three
applications and a peer-dependent package, the version that ships is decided by
the lockfile, not by the three ranges.

**The supply-chain gate stays red, deliberately.** `vitest < 3.2.6` is the
remaining critical and is a major upgrade across ~5,000 tests — a change that
must be able to fail loudly on its own, not inside a commit about something else.
The 21 highs are `vite`, `multer`, `sharp`, `xlsx`, `js-yaml` and
`@opentelemetry/propagator-jaeger`. The gate is blocking and should stay
blocking; it carried `continue-on-error` until recently and was decorative.

Also repairs a stale `apps/web/node_modules/.bin/next` shim left pointing at a
hoisted path that no longer existed, which broke the web build after the bump.

`pnpm verify` 15/15, 0 skipped. Web builds on Next 15.5.22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the architecture Architecture / foundation governance label Aug 6, 2026
kannan19302 and others added 3 commits August 6, 2026 20:44
…ally pins

`mobile-ci` runs `dart format --output=none --set-exit-if-changed .` and had been
red on 736 of 799 files, so `flutter analyze` and `flutter test` never ran either
— three checks reported by one failing step.

The formatting is mechanical. Getting it *right* is not, and this is the part
worth recording: Flutter 3.27.0 pins Dart 3.6, and Dart 3.7 replaced the
formatter wholesale with the tall style. Running `dart:stable` (3.12) reformatted
769 files into a style this workflow's Dart would still reject — a very large
diff that looks like a fix and is not one. Reverted, re-run under `dart:3.6` in
Docker to match what `subosito/flutter-action` installs, then re-run with
`--set-exit-if-changed` to prove the result is idempotent.

Pure line-wrapping. No behaviour change.

Also deletes `auto_fix_router.py`, `fix_router.py` and `fix_pos_routes.py` from
`apps/mobile`. They are one-off routing fixers holding absolute paths into a
`OneDrive\Documents\Antigravity\ERPSys` tree and a Gemini IDE task log, and
nothing references them — the "no one-off scripts, temp files, or debug
artifacts left behind" rule, verbatim.

`flutter analyze` and `flutter test` are still unverified locally: this machine
has no Flutter toolchain, and CI is the first thing that will run them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Static (contracts)` failed on the runner and passed on a Windows checkout, and
`--record` could not reproduce it because there was nothing to see locally.

`providerEntryFor` resolved `@unerp/ui/<subpath>` through a hardcoded `dist`
root with no fallback — unlike the package's main entry, which `DIST()` already
falls back from `dist` to `src`. A developer machine has `packages/ui/dist`
built, so `@unerp/ui/charts`, `/layout`, `/notifications` and `/theme` counted as
providers. The `Static (contracts)` job runs `pnpm install` and `pnpm
db:generate` and no build, so on the runner all four resolved to `null` and
disappeared from every consumer's expectations.

The unreproducible failure was the visible symptom. The one that matters is that
**a gate's coverage depended on build state, and it under-reported rather than
failed** — the same shape as the RLS suite that checked only `ENABLE`, the CDC
harness whose typecheck functions were never called, and the policy rule that
pointed at a moved file. Four providers were simply not being replayed on CI, and
nothing said so.

Found only because the previous commit made this gate name what drifted instead
of printing the word "stale". The CI log read `provider removed:
@unerp/ui/charts` and that was the whole diagnosis.

Subpath resolution now tries `dist` then `src`. Proven by reproducing the CI
condition exactly — move `packages/ui/dist` aside, run, move it back: green in
both states, where before it was green only with `dist` present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The output was, verbatim:

    API did not become ready in time
    ✓  API
    IdP did not become ready in time
    ✓  IdP

…followed by the summary of URLs, as though the stack were healthy. `wait()`
returns `false` on timeout and all three call sites discarded the boolean.

This script's header says it "waits until each is genuinely answering rather than
merely started", and forty lines above the bug it refuses to report success for a
process it did not start, because that would be "precisely the kind of false
green this codebase has been full of". It was one itself, in the one place a
developer looks to find out whether the stack is up.

It now reports what `wait()` returned, names the services that are not
answering, and says the processes may still be compiling — a report rather than
a shutdown, with `pnpm smoke` named as the check that actually settles it.

The deadline also goes from 3 minutes to 8. A cold `nest start --watch` across 45
modules takes four to six minutes here, so three was declaring failure on a
service compiling perfectly normally that would answer a minute later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

async bulkEvaluateFeatureFlags(tenantId: string, flagKeys: string[]) {
const results: Record<string, boolean> = {};
for (const key of flagKeys) results[key] = true;
@ApiOperation({ summary: "Create feature flag rule" })
@Permissions("saas.flags.admin")
async createFeatureFlagRule(@CurrentUser() user: any, @Body() ruleData: any) {
return this.service.createFeatureFlagRule(user.tenantId, ruleData);
@ApiOperation({ summary: "Publish marketplace app" })
@Permissions("saas.marketplace.admin")
async publishMarketplaceApp(@CurrentUser() user: any, @Body() appData: any) {
return this.service.publishMarketplaceApp(user.tenantId, appData);
@CurrentUser() user: any,
@Body() exportParams: any,
) {
return this.service.createDataExportJob(user.tenantId, exportParams);
@CurrentUser() user: any,
@Body() webhookData: any,
) {
return this.service.registerSaasWebhook(user.tenantId, webhookData);
};

await idpPrisma.$transaction(async (tx) => {
for (let i = 0; i < records.length; i++) {
const redactKeys = ["password", "token", "secret", "apiKey", "key"];
const clone: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
clone[k] = redactKeys.some((r) => k.toLowerCase().includes(r))

/** Deterministic, non-reversible hash used for lookup + verification. */
export function hashApiKey(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
const val = formData[k];
const safeVal =
typeof val === "string"
? `'${val.replace(/'/g, "\\'")}'`

// Listen for messages from iframe
useEffect(() => {
const handleMessage = (e: MessageEvent) => {
@kannan19302
kannan19302 merged commit b1c2fbc into main Aug 6, 2026
14 of 17 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

architecture Architecture / foundation governance backend NestJS API ci database Prisma / PostgreSQL schema or migrations dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation frontend Next.js web app ui @unerp/ui-* design system packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants