# PRD — nodehtmltopdf

Evidence date: 2026-10-08.

Source: `git@bitbucket.org:overdrive/nodehtmltopdf.git`, `master` at `dc92bed` (2022-02-09).

`README.md` is the only requirements text in the source repository. There was no prior Silicon Ark set for this slug. Requirements below are recovered from `README.md`, `index.js`, `index_wit SM.js`, `SecretsManager.js`, `config.js`, `package.json`, and the 18 commits on `master`. Provenance is labelled. AWS key material in `cred.json` is omitted.

Where the README and `index.js` disagree, both are recorded. The running entry is `index.js`.

## Product purpose

**Documented.** `README.md` introduction: a Node.js microservice created by Silicon Overdrive to generate PDF documents from HTML, because Lambda struggled to generate PDFs in that environment. The caller posts JSON to the server IP on port 8080. The service returns JSON with a filename and a path.

**Implemented.** `index.js` listens on port 8080 and, on `POST /htmltopdf`, renders `req.body.html` to a local PDF and uploads it to S3.

**Inferred.** Bucket name `tmt-pmf-prod-invoices`, the log line `PMF PDF Generator`, and the README's PhantomJS and PM2 install are consistent with a Pay My Fines receipt helper that runs beside Lambda rather than inside it. This repository does not name Pay My Fines.

**Unknown / Requires Confirmation:** the product owner, whether the process is still deployed, and which system posts the HTML.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| HTTP caller posting JSON | README: "post to the server IP address on port 8080 with the JSON Payload". `POST /htmltopdf` reads `req.body` | **Documented** and **Implemented**. The caller is not named in this repo |
| Operator on an Amazon Linux 2 host | README Quick Start and Install | **Documented**. No host inventory is in git |
| Person running `npm start` | `package.json` script `node index.js` | **Implemented** |
| Person running `pm2` | README Testing and Install | **Documented**. `pm2` is also an npm dependency. No ecosystem file starts it |
| AWS principal in `cred.json` | `AWS.config.loadFromPath('./cred.json')` | **Implemented**. The IAM user name is not in the file. Keys omitted |

No login, session, API key, or role check exists in this repository.

Silicon Ark's `pmf-backend-ah` PRD records a caller of `POST http://10.106.3.158:8080/htmltopdf` that then reads the PDF from `tmt-pmf-prod-invoices`. That address and that caller are not in this repository. Treating `pmf-backend-ah` as the user of this revision is **Inferred** and **Requires Confirmation**.

## Systems the product talks to

1. The HTTP caller. Inbound JSON. No authentication in `index.js`.
2. Local PhantomJS, through `html-pdf` `pdf.create(...).toFile`. The README also installs a system PhantomJS 2.1.1 binary. `phantomPath` in `index.js` is commented out, so the library default is what the code selects.
3. The local filesystem. The PDF is written as `filename` in the process working directory (`toFile(filename, ...)` then `fs.readFile(filename, ...)`). An earlier `/tmp/` path is not in the HEAD file.
4. Amazon S3, region `eu-west-1` (the region value in `cred.json`, and the host in the response URL). Bucket `tmt-pmf-prod-invoices`. Credentials from `cred.json`.
5. PM2 and CloudWatch, named only in the README. No PM2 config and no CloudWatch SDK call are in git.

`index_wit SM.js` would also call AWS Secrets Manager in `eu-west-1` for secret id `pmf-prod-s3-user`. That file is not `npm start`.

No database, queue, mailer, or Lambda function is defined in this repository. The README says the service exists because Lambda was a poor place to render PDFs, and that Lambda would need the same security groups if it is separate. Those sentences do not name a function.

## Major workflows

### WF-1 — Install and start, as the README states

**Documented** in `README.md` Install. Not a script in the repo.

1. On Amazon Linux, install `gcc-c++`, `make`, Node.js from NodeSource `setup_16.x`, `git`, global `pm2`, and packages `glibc`, `fontconfig`, `freetype`, `freetype-devel`, `fontconfig-devel`, `wget`, `bzip2`.
2. Download `phantomjs-2.1.1-linux-x86_64.tar.bz2` from `https://bitbucket.org/ariya/phantomjs/downloads/` and symlink `phantomjs` to `/usr/bin/phantomjs`. The README does not show the extract or the install path that the symlink assumes (`/usr/local/share/phantomjs-2.1.1-linux-x86_64/bin/phantomjs`).
3. `git clone https://github.com/TMTServices/pmf-pdfgeneration.git`, then `cd pmf-pdfgeneration/`, `npm install`, `pm2 start index.html`, `pm2 log`.

**Requires Confirmation.** This git remote is `git@bitbucket.org:overdrive/nodehtmltopdf.git`. The start file in `package.json` is `index.js`, not `index.html`. Whether the GitHub URL is another copy of this service is unknown.

Quick Start is a shorter variant: create an AWS account, start "a AWS Linux 2 IAM", install software, clone the repo, run the index file. "AWS Linux 2 IAM" is not a precise resource. **Requires Confirmation.**

### WF-2 — Process start (`index.js`)

**Implemented.**

1. `npm start` runs `node index.js`. `package.json` `main` is `index.js`.
2. Express is created. `cors()` is mounted with no options. `body-parser` accepts urlencoded (`extended: false`) and JSON.
3. `AWS.config.loadFromPath('./cred.json')` runs at load. A missing or invalid file throws before `listen`. The keys are omitted here. Region in that file is `eu-west-1`.
4. `new AWS.S3()` is created once at module scope.
5. `require('./config.js')` and `require('./SecretsManager.js')` run. Neither module is called later in this file.
6. `app.listen(8080)` logs `HTML2PDF: 8080!`.

`npm run deploy` is `npm run build && npm run start`. There is no `build` script. **Implemented** script that fails at `npm run build`. The README does not use `deploy`.

### WF-3 — Generate a PDF (`POST /htmltopdf`)

**Documented** at the level of "post JSON, receive filename and path". The field names and the S3 behaviour are **Implemented** in `index.js`.

1. The handler logs `req.body`.
2. If `req.body.html` is missing, it logs `unable to get the data` and `return`s. It does not call `res.send` or `res.status`. The HTTP response stays open. A commented `callback(errorResponse)` refers to names that are not defined.
3. Filename is `` `${Math.random().toString(36).slice(2)}.pdf` ``.
4. `pageSize` is `req.body.pagesize` or `'a4'`. It is not passed to `html-pdf`.
5. `req.body.data` is stored in `data` and never read.
6. Options are `{ format: 'Letter' }`. A `phantomPath` line is commented out.
7. The handler builds `{ filename, path }` where `path` is `https://tmt-pmf-prod-invoices.s3.eu-west-1.amazonaws.com/` plus the filename.
8. `pdf.create(html, options).toFile(filename, callback)` is started.
9. `res.send(result)` runs immediately, before that callback.

Inside the callback:

1. On `err`, the error is logged. `uploadFile` still runs.
2. `fs.readFile(filename)` loads the PDF. On error it `throw`s. That throw is not in the Express handler, so it is not turned into an HTTP error. The client has already been answered.
3. `S3.upload` uses `Bucket: 'tmt-pmf-prod-invoices'`, `Key: filename`, `Body: data`, and `contentType: 'application/pdf'`. `ACL: 'public-read'` is commented out. On S3 error the callback `throw`s.
4. Success logs `File uploaded successfully at ${data.Location}`. The location is not sent to the client. The response `path` was built from the bucket name, not from `data.Location`.
5. The local PDF file is not deleted.

`config.js` exports `bucket: 'tmt-pmf-prod-invoices'`. `index.js` does not read it. Changing `config.js` does not change the upload.

The upload parameter key is `contentType`. The AWS SDK v2 upload parameter is `ContentType`. **Inferred** that the lowercase key is ignored and the object content type follows the SDK default. **Requires Confirmation** against a real upload.

### WF-4 — Echo routes

**Implemented.** Not described in the README.

- `POST /pdf` sends `req.body` back as the response. It does not render a PDF.
- `GET /` logs `PMF PDF Generator` and sends `req.body` back. A GET usually has no body, so the payload is whatever body-parser attached.

### WF-5 — Secrets Manager variant (not the start script)

**Implemented** in `index_wit SM.js` and `SecretsManager.js`. Saved in `dc92bed` when the live `index.js` was reverted to `cred.json`.

1. `app.listen` is `async`. It calls `SecretsManager.getSecret('pmf-prod-s3-user', 'eu-west-1')` and stores the string on `rValue`. Failures are logged. The port log runs only inside the `try`.
2. `POST /htmltopdf` does `JSON.parse(rValue)` and reads `Access_key_ID`, `Secret_access_key`, and `Invoice_Bucket`.
3. It calls `AWS.config.loadFromPath` with that object. `loadFromPath` in the AWS SDK expects a filesystem path. **Inferred** that passing an object does not load those keys. **Requires Confirmation** only if someone starts this file.
4. The response `path` is still the hardcoded host `tmt-pmf-prod-invoices`, even when `Invoice_Bucket` differs.
5. A request that arrives before `getSecret` resolves parses `undefined` and throws. There is no guard.

`SecretsManager.getSecret` uses `AWS.SecretsManager` in the given region, `getSecretValue({ SecretId })`, and returns `SecretString`, or base64-decodes `SecretBinary` with `new Buffer` (deprecated in Node 16). Named failures are rethrown: `DecryptionFailureException`, `InternalServiceErrorException`, `InvalidParameterException`, `InvalidRequestException`, `ResourceNotFoundException`. Any other error is swallowed and the function returns `undefined`.

The secret's JSON shape is only the three property names above. Whether secret `pmf-prod-s3-user` contains them is **Unknown / Requires Confirmation**.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure in this repo |
|---|---|---|---|---|---|
| HTTP caller | Inbound | `POST /htmltopdf` | JSON `html`; optional `pagesize` and `data` are read and unused | None | Missing `html`: log and no HTTP response. Other errors: client already received `{ filename, path }` |
| `html-pdf` / PhantomJS | In process | After the response is queued | HTML string, format `Letter` | None | Logged. Upload still attempted |
| Local disk | Write then read | `toFile` then `readFile` | PDF bytes | Process user | `throw` in the callback |
| S3 `tmt-pmf-prod-invoices` | Outbound | After the file is read | Key = filename, body = bytes | `cred.json` (values omitted) | `throw` in the S3 callback |
| Secrets Manager `pmf-prod-s3-user` | Outbound | Only if `index_wit SM.js` is the process | Secret string | Default AWS chain inside that file, which does not load `cred.json` | Logged in `listen`; request path can throw |
| PM2 / CloudWatch | Not in code | README testing section | `pm2 log` | **Unknown** | **Unknown** |

`paymyfines-backend-new` Silicon Ark documents a different secret id, `pmf-pdf-server`, whose field `IP` is used as an HTTP POST URL for receipt HTML. This repository does not mention `pmf-pdf-server`. Whether that URL points here is **Unknown / Requires Confirmation**.

## Important constraints

- Port is the literal `8080`. README and code agree. There is no environment override.
- Page format sent to `html-pdf` is `Letter`. The `a4` default on `pagesize` does not affect the file.
- The HTTP success body does not mean the object is in S3. `pmf-backend-ah` technical documentation says that caller waits 1.5 seconds and then `GetObject`s `filename`. That wait is in the other repository, not in this one. **Requires Confirmation** that 1.5 seconds is enough for this process.
- Filename entropy is `Math.random().toString(36).slice(2)`. **Inferred** that two concurrent calls can collide, and that the name is not a secret.
- No request size limit is set on `body-parser` beyond the library default.
- CORS middleware is mounted with no arguments. Who may call it from a browser is the `cors` package default. **Requires Confirmation** if a browser is a caller. The README describes a server-to-server post.

## Requirements discovered from the implementation

### FR-001 — Accept HTML and return a filename and path

**Requirement.** A caller can post HTML and receive JSON containing `filename` and `path`.

**Who.** Unauthenticated HTTP caller.

**Trigger.** `POST /htmltopdf`.

**Inputs.** JSON body. `html` must be truthy. `pagesize` and `data` may be present. They do not change the PDF.

**Behaviour.** See WF-3. Response is sent before render and upload finish. `path` is an `https` virtual-hosted S3 URL for `tmt-pmf-prod-invoices` in `eu-west-1`.

**Validation.** Only the missing-`html` check.

**Errors.** Missing `html` does not end the response. Render and S3 errors are logged or thrown after the response.

**Evidence.** `README.md` Introduction; `index.js` `app.post('/htmltopdf', ...)`.

**Implementation.** Implemented.

**Confidence.** High for the code path. Medium for the README, which does not name the route or the fields.

### FR-002 — Store the PDF in the production invoice bucket

**Requirement.** The generated file is uploaded to S3.

**Behaviour.** Bucket string `tmt-pmf-prod-invoices`, key equal to `filename`, body equal to the file bytes. Public-read ACL is commented out since `b62094a`.

**Evidence.** `index.js` `S3.upload`. Commit subjects "update web accessable pdf" and "update acl to private".

**Implementation.** Implemented. Object ACL is the bucket default, which is not in git.

**Confidence.** High for the API call. Low for the resulting object ACL.

### FR-003 — Run outside Lambda on port 8080

**Requirement.** The service is a long-running Node process on port 8080, supervised by PM2 on Amazon Linux 2, because Lambda PDF generation was a problem.

**Evidence.** `README.md` Introduction, Requirements, Install. `app.listen(port)` with `port = 8080`.

**Implementation.** The listen port is implemented. PM2, Amazon Linux, and the Lambda limitation are documented. They are not implemented as code in this repo.

**Confidence.** High for the port. Low for the host still being up.

### FR-004 — Do not publish the object with `public-read`

**Requirement.** Commit `b62094a` is titled "update acl to private" and comments out `ACL: 'public-read'`.

**Implementation.** Implemented as the absence of an ACL parameter. Whether the bucket then keeps the object private is **Unknown / Requires Confirmation**.

**Confidence.** High for the diff. Low for the bucket policy.

## Behaviour the README does not state

These are implemented and were not promoted into documented requirements:

- `POST /pdf` and `GET /` echo the body.
- The response is not tied to upload success.
- `pagesize` does not select the page format.
- Credentials are a committed JSON file on the running entry.
- A second entry file loads Secrets Manager and is not wired to `npm start`.
