Make the platform actually run: registry consumption, packaging, auth, and the dev loop - #143
Conversation
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.
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>
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>
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
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>
|
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:
For more information about GitHub Code Scanning, check out the documentation. |
`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>
…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) => { |
What this is
Everything was green and nothing worked.
pnpm verifyreported 14/14 and thedocument 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 mockspnpm verify14/14, nothing skippedThe defects
Packaging — three packages emitted ESM while declaring CommonJS.
tscput"use strict"above"use client", silently turning all 44 client componentsinto server components.
export *loses its names across the RSC boundary, soToastProviderarrivedundefined. The design system shipped no CSS, thenshipped CSS its own output could not
require; CSS modules are now resolved atbuild time so the package loads in a bundler, in Node, or in vitest.
Auth —
JwtAuthGuardchecked session revocation against a table with RLSENABLE+FORCE, before any tenant context existed, so it returned zero rowsand rejected every request while the session sat there active. Authentication
was impossible against a correctly-secured database.
set_config(..., true)istransaction-local, so setting the tenant GUC outside a transaction discarded it.
The IdP never put
permissionsinto the token the API'sRbacGuardreads./auth/meandrefreshSessionread atenantrelation the IdP schema does nothave.
Authorisation — nine controllers used
TenantGuardwith noJwtAuthGuardto populate
request.user, so they authenticated nobody. Now a HARD policyrule, because this is the same shape as
@Permissionswithout 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 loop —
pnpm devstarts 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.