# Technical documentation — ipw_new

Evidence date: 2026-10-11.

Source: `git@bitbucket.org:overdrive/ipw_new.git`, `main` at `111772e` (2025-05-26, Dirk, "adding unhead package").

This document describes the tree that was inspected. It does not prescribe a new architecture. Secrets are not copied. `.env.template` values for secrets are empty strings.

## Repository layout

```text
ipw_new/
  bitbucket-pipelines.yml
  build/build.sh
  build/build-package.cjs
  client/                 Nuxt 3 application
  dist/                   compiled server output (out of date versus server/src)
  ipw table adjustments.sql
  README.md
  server/                 NestJS 10 application
  sonar-project.properties
```

Root `.gitignore` ignores `dist/node_modules`, `dist/development.config.js`, `dist/staging.config.js`, and `dist/production.config.js`. It does not ignore the rest of `dist/`.

`sonarqube_scan.bat` was named in the onboarding inventory and is not in this revision.

## Languages and frameworks

| Part | Evidence |
|---|---|
| Server language | TypeScript 5.2 (`server/package.json`) |
| Server framework | `@nestjs/common` and `@nestjs/core` `^10.2.7`, platform `@nestjs/platform-express` `^10.2.7` |
| ORM | `prisma` and `@prisma/client` `^5.4.2`. Schema path `server/src/prisma/schema.prisma` |
| Database | MySQL via `provider = "mysql"` |
| Client | Nuxt `^3.17.4`, `ssr: false`, `target: 'static'`, Vue through Nuxt |
| UI | Bootstrap `^5.3.6`, Sass `^1.89.0` (devDependency) |
| i18n | `@nuxtjs/i18n` `^8.0.0-rc.4` |
| Node | Client `engines.node` is `^18.0.0`. Server does not declare `engines` |

The root README's Fastify statement is not true of this tree. `nest-cli.json` is present. There is no Fastify import under `server/src`.

`@casl/*` and `nest-casl` are installed and unused. `@pinia/nuxt` is installed and the Nuxt module line is commented out. `express-session` and `express-mysql-session` are installed and the middleware is commented out. `main.ts` contains a TODO to remove them.

## Application structure

### Server modules

Registered in `server/src/app.module.ts`:

`ConfigModule`, `ServeStaticModule`, `PrismaModule` (imported twice), `CsrfModule`, `AuthModule`, `UsersModule`, `AdminModule`, `EmailModule`, `RolesModule`, `PublicModule`, `ReportsModule`, `UploadModule`, `ManagementModule`, `GroupModule`, `MemberModule`, `AuditModule`.

Global guards, in order: `AccessTokenGuard`, then `RolesGuard`.

`LoggerMiddleware` is commented out. No global `ValidationPipe`.

### Route prefix

`app.setGlobalPrefix('api')` in `main.ts`. Client callers use that prefix through `client/api/index.js`.

### Controllers and paths

| Controller | Prefix | Routes |
|---|---|---|
| `AuthController` | `auth` | `POST login`, `POST reset`, `POST refresh`, `POST logout`, `POST confirm/:type/:userHash/:token` |
| `CsrfController` | `csrf` | `GET /` |
| `UsersController` | `user` | contact details, groups, join, pending, profiles, `PUT member`, `DELETE :id`, `PATCH :id`, `GET /`, `GET :id`. Several handlers are commented out |
| `AdminController` | `admin` | users, member activity, live users, roles, members, groups |
| `RolesController` | `role` | descriptions, all-descriptions, `user/:id` |
| `ReportsController` | `report` | years, group-names, control-products, table-report, csv-report |
| `UploadController` | `upload` | categories, documents, document, all-profiles, all-documents, download-document |
| `AppController` | (root) | no handlers |
| `GroupController`, `MemberController`, `AuditController`, `ManagementController`, `PublicController` | `group`, `member`, `audit`, `management`, `public` | no handlers |

### Client

Nuxt file routing under `client/pages/`. Auto-imports are disabled (`imports.autoImport: false` in `nuxt.config.js`). Pages import composables and APIs explicitly.

Middleware files: `isLoggedIn.js`, `isNotLoggedIn.js`, `isAdministrator.js`. `validateLogin.js.old` is unused.

API wrappers: `client/api/index.js` (base `fetch`, CSRF, bearer header from the access cookie), plus `auth.js`, `user.js`, `admin.js`, `role.js`, `report.js`, `upload.js`.

## Authentication

Passport local strategy (`username`, `password`) calls `AuthService.validateUser`.

JWT:

- Access token secret: `JWT_ACCESS_TOKEN_SECRET`, `expiresIn: '15m'`. Claims: `sub` (user id), `username`, `user_class_id`, `roles`.
- Refresh token secret: `JWT_REFRESH_TOKEN_SECRET`, `expiresIn: '1d'`.
- The refresh token is stored as an Argon2 hash on `user_refresh_token`, one row per user.
- `auth/constants.ts` exports `jwtConstants.secret = 'test213'`. No strategy imports that file. The live secrets are the environment variables.

Cookie names are `__${DOMAIN}-auth`, `__${DOMAIN}-refresh`, `__${DOMAIN}-access`, and the CSRF cookie name cleared on logout is `__${DOMAIN}-x-csrf-token`.

The access token presented to Passport is rebuilt in `parseReqToken`: signature from the signed httpOnly cookie, header and payload from the `Authorization: Bearer` header. `ExtractJwt.fromAuthHeaderAsBearerToken` is commented out.

`AccessTokenStrategy` loads the user with `findByIdWithRoles`. It does not put the JWT `roles` claim straight onto the request; roles come from the database join.

On logout, `user_refresh_token.refresh_token` is set to null. There is no token denylist for the access token beyond the cookie being cleared and the 15-minute expiry.

`RolesGuard`, if the request has no `req.user` on a non-public route, calls `process.exit(1)`.

## Password hashing

- New hashes: `argon2.hash` / `argon2.verify` (package `argon2` `^0.31.1`). The call does not pass an options object, so the library defaults apply. The OWASP comment in `checkNewPasswordStorage` mentions Argon2id. The code does not set `type: argon2.argon2id` explicitly.
- Legacy hashes: `md5` package, compared to `users.password`.

## CSRF

`csrf-csrf` double-submit, initialised in `CsrfService.initDoubleCsrf` and mounted in `main.ts` before listen. `GET /api/csrf` is `@Public` and returns `generateToken`. A comment in the controller questions why each call generates a new token.

## Email

`EmailService` builds a Nodemailer transporter from `SMTP_*`. `secure: false` with a comment "upgrade later with STARTTLS". TLS ciphers are set to `SSLv3`. `verify()` exists and is not called from `main.ts`.

Templates on disk: `server/src/email/template/registration.html.ejs`, `registration.text.ejs`, `reminder.html.ejs`, `reminder.text.ejs`. `nest-cli.json` copies `email/template/` into the build.

`node-schedule` timings:

- `NODE_ENV` of `staging` or `production`: `*/1 * * * *`
- otherwise: `5 * * * * *`

The job calls `sendScheduled`. Startup resets `cronjob.processing` on id 1 if it was true.

Only `users.service.create` was found calling `emailService.create`, and only with template `registration`.

## File storage

`UploadService.getBasePath` uses `UPLOAD_DIRECTORY`. Files are written with `writeFileSync` to `{directory}/{uuid}{original extension}`. Metadata goes to `document_upload`. Download calls `res.download`. The handler is `GET` and reads `document_upload_id` from the body. Multer is `FileInterceptor('file')` with no `limits` and no `fileFilter`.

## Database

Prisma schema is an introspection of an existing MySQL database (`///` comments on models that lack a unique identifier, including `admin_sessions`). It is not a set of Prisma migrations. There is no `server/prisma/migrations` directory.

`package.json` script `prisma` is `npx prisma db pull && npx prisma generate`. That pull overwrites the schema from the database. The README's requested `generator` `output = "./generated"` is not in the schema. The generator block is only `provider = "prisma-client-js"`.

Models include the legacy IPW tables (`users`, `user_group`, `rbac_*`, `eval_form`, `group_eval_form`, `group_probation`, `sawis_compliance`, `sawis_farm`, `auditlog`, `ipw_sessions`, `htmlpage`, and others) and the newer tables created in `ipw table adjustments.sql`:

- `cronjob`
- `document_category`
- `document_upload`
- `email`
- `user_password`
- `user_refresh_token`
- `user_registration_token`
- `user_reset_token`

`user_selected_group` is commented out in the SQL file and is not a Prisma model.

The SQL file is not executed by the application. After the `CREATE TABLE` statements it contains informal "In cronjob / In document_category / In rbac_roles" row text, not `INSERT` statements.

The README tells a developer to import `ipw_live.sql` from SharePoint using MySQL Workbench. That dump is not in the repository. Commit `b0dc8a5` ("Add new DB dump") changed only `README.md`.

Template connection string shape: `mysql://root:password@localhost:3306/ipw_development?connection_limit=9`. The password in the template is the placeholder `password`.

`BigInt.prototype.toJSON` is patched in `main.ts` to `parseInt`.

## Environment

`ConfigModule.forRoot` loads `` `.env.${NODE_ENV}` ``. `NODE_ENV` is read when `app.module.ts` is evaluated, so it must already be set in the process environment. The README tells developers to create `.env`. The template file is `.env.template`. Those names do not match `.env.development`.

`main.ts` declares this `ProcessEnv` shape:

`NODE_ENV`, `DOMAIN`, `ADAPTER_IP`, `PORT`, `DATABASE_URL`, `SESSION_SECRET`, `CSRF_SECRET`, `COOKIE_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET`, `HASHID_SALT`, `CLIENT_ORIGIN`, `SMTP_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `UPLOAD_DIRECTORY`, `NUXT_API_LOCATION`, `NUXT_CLIENT_LOCATION`.

Mismatches:

| Key the template or comments mention | Key the running code reads |
|---|---|
| `HASHID_SALT` in `.env.template` and `ProcessEnv` | `process.env.SQIDS_SALT` in `createHashidsInstance` |
| `NUXT_CLIENT_LOCATION` used for confirmation links | absent from `.env.template` |
| Compiled `dist/users/users.service.js` uses `HOSTNAME` | TypeScript source uses `DOMAIN` inside the destructure. The link itself uses `NUXT_CLIENT_LOCATION` in both |

`random_sqids_salt.js` is a small script in `server/` for generating a salt. Its output was not executed for this document.

Client template (`client/.env.template`): `ENV`, `HOST` `127.0.0.1`, `PORT` `8080`, `NUXT_API_LOCATION` `http://127.0.0.1:9000`. Server template: `PORT` `9000`, `ADAPTER_IP` and `DOMAIN` `127.0.0.1`. `nuxt.config.js` does not set `devServer.port`. Whether Nuxt reads `PORT` from `.env` follows Nuxt's own env rules. The README states frontend 8080 and backend 9000. That was not started during this inspection.

`CLIENT_ORIGIN` must be JSON. `main.ts` does `JSON.parse(CLIENT_ORIGIN)` and passes `origin` to `enableCors`, with credentials and methods `OPTIONS`, `GET`, `PUT`, `POST`, `PATCH`, `DELETE`.

## Build and run

Server scripts:

| Script | Command |
|---|---|
| `start` | `nest start` |
| `start:dev` | `nest start --watch` |
| `start:debug` | `nest start --debug --watch` |
| `build` | `nest build` |
| `start:devpm2`, `start:staging`, `start:production` | `pm2 start` of config files that are not in the tree |
| `test` | `jest` |
| `test:e2e` | `jest --config ./test/jest-e2e.json` |
| `lint` | eslint with `--fix` |

Client scripts:

| Script | Command |
|---|---|
| `start` | `nuxi dev` |
| `build:dev` | `ENV=development nuxi generate` |
| `build:staging` | `ENV=staging` and `NUXT_API_LOCATION=https://staging.ipw.co.za` |
| `build:production` | `ENV=production` and `NUXT_API_LOCATION=https://ipw.co.za` |
| `preview` | `nuxi preview` |

`client/README.md` documents `npm run dev`, `generate-dev`, `generate`, and `generate-production`. Those script names are not in `package.json`.

`build/build.sh`:

1. Deletes `dist/*` and `server/dist/*`.
2. `npm run build` in `server` and copies `server/dist` to `../dist`.
3. Copies `schema.prisma` to `dist/prisma`.
4. Copies `staging.config.js` and `production.config.js` from `server/` (those files are absent).
5. Rewrites `dist/package.json` with `start:staging` and `start:prod` set to the string `todo`.
6. Runs `npm run build:staging` in `client` and copies `.output/public` to `dist/static`.

The script is bash. A Windows `sonarqube_scan.bat` is not in git. No Dockerfile and no `docker-compose` file are in the tree.

`ServeStaticModule` in development points at `client/.output/static`. A `nuxi generate` build writes `.output/public` (the path `build.sh` copies). The development static path and the generate output path are different. Whether `.output/static` is populated is **Unknown / Requires Confirmation**.

## Pipeline and analysis

`bitbucket-pipelines.yml` runs on branch `main` only. One step, "SonarQube analysis", pipe `sonarsource/sonarqube-scan:3.0.2`, with `SONAR_HOST_URL` and `SONAR_TOKEN` from repository or workspace variables, `EXTRA_ARGS` forcing embedded Java, clone depth full, and a Docker service memory of 3072 MB. There is no test step and no deploy step.

`sonar-project.properties` sets `sonar.projectKey=overdrive_ipw_new_fdf07374-444d-4eda-ac32-7a46bac73158`.

## Tests

Jest is configured with `rootDir` `src` and `testRegex` `.*\.spec\.ts$`. The spec files under each module are the Nest schematic default (`should be defined`) plus `app.controller.spec.ts` and `app.service.spec.ts`. They do not assert login, roles, or SQL.

`server/test/app.e2e-spec.ts` requests `GET /` and expects 200 and body `Hello World`. `AppController` does not implement that route. The e2e test does not match the application.

No client test runner is configured in `client/package.json`.

## Security notes visible in the tree

These are observations, not a penetration test.

- MD5 remains a successful login path for unmigrated users.
- `jwtConstants.secret` is the string `test213` and is unused by the strategies. Real signing uses environment secrets.
- Argon2 is called without an explicit type or memory option.
- DTOs have no `class-validator` decorators, and the pipe is not global, so body shape is not enforced by Nest.
- `RolesGuard` exits the process when `req.user` is missing.
- Upload accepts any file Multer parsed and stores it under a UUID name with the original extension.
- Email TLS ciphers are set to `SSLv3`.
- `updatePassword` writes an Argon2 hash into `users.password` through `usersService.update`. That column is `Char(32)`. Nothing in the controllers was found calling `updatePassword`. If it were called, the hash would not fit the column.
- Group search builds a SAWIS comparison with a broken quote when the search text is numeric (`group.service.ts`).
- Cookie `secure` and the base `httpOnly` flag depend on `NODE_ENV` being `staging` or `production`. Development cookies are not marked secure.
- The access-payload cookie max age is two minutes while the JWT expiry is 15 minutes.

## Development process evidence

Authors on `main`: Jaco Brits (`jaco@overdrive.co.za`) for the import and pipeline, Jason Wandrag for the README, Dirk (`dirk@overdrive.co.za`) for the `unhead` commit. `package.json` author fields still say Craig van Tonder. Client package URLs still point at GitHub `WeboonTechnologySolution/ipw.co.za.com`.

The README contributing steps add git remotes `staging` and `production` on GitHub and push the same commit there. This clone has only `origin` on Bitbucket and branch `main`.

## Related Silicon Ark project

`silicon_ark/ipw-php/` documents a different repository (`overdrive/ipw-php`) that is also an IPW member and staff application, written in CodeIgniter 4, with later commits than `111772e`. `ipw_new` does not reference it. Do not treat the two trees as one codebase.

## Unknown / Requires Confirmation

- The host, process manager, and database name actually used for `ipw.co.za` and `staging.ipw.co.za`, if this build was ever deployed there.
- Where the PM2 config files live, if they still exist.
- Whether `ipw_live.sql` matches `schema.prisma`.
- Whether the committed `dist/` is what any server runs.
- Whether the Dart Sass blank-page issue in the README still reproduces on Nuxt 3.17 with Sass 1.89.
- The operational owner of this repository after 2025-05-26.
