Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: CI

# pull_request, deliberately, and never pull_request_target. On a public
# repository pull_request_target runs with the base repository's secrets and a
# writable token while checking out an outside contributor's code, which is the
# standard route for a malicious pull request to exfiltrate them. This workflow
# needs no secrets at all, and asks for read-only contents so that a compromised
# step has nothing useful to reach for.
on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- name: Lint
run: npm run lint

- name: Unit tests
run: npm test

- name: Theme contrast and colour vision checks
run: npm run palette

- name: Production build
run: npm run build

- name: Audit dependencies
run: npm audit --audit-level=high

secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# The binary is fetched and checksummed rather than pulled in as a third
# party action, so this step depends on nothing that can change under it
# between runs.
- name: Install gitleaks
env:
GITLEAKS_VERSION: 8.21.2
GITLEAKS_SHA256: 5bc41815076e6ed6ef8fbecc9d9b75bcae31f39029ceb55da08086315316e3ba
run: |
curl -sSLo gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c -
tar xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks

# Scans the tree as it would be merged, which is what stops a new secret
# landing. Full history is not scanned here: it contains one accepted,
# already rotated finding, and failing every build on it forever would
# only teach people to ignore this job. Scan history manually with
# `gitleaks git . --log-opts="--all" -c .gitleaks.toml` after any change
# to the rules.
- name: Scan for secrets
run: ./gitleaks dir . -c .gitleaks.toml --redact --no-banner
58 changes: 58 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
title = "PayReckon gitleaks configuration"

# Supplements the default ruleset rather than replacing it.
#
# Why this file exists: gitleaks 8.21.2 scanned this repository's full history
# and exited clean while a live Web3Forms access key sat in a committed test
# file. The default rules do not match bare UUIDs, because a UUID carries too
# little entropy to trip a generic detector and is indistinguishable from any
# other identifier. A UUID is precisely the credential shape this project uses,
# so the one class of secret that can realistically leak here was the one class
# nothing was looking for.
#
# Two rules, deliberately. The first is the high confidence signal: a UUID
# assigned to something that names itself a credential. The second is the
# backstop, and it is the one that matters, because the key that actually
# leaked was assigned to a constant called VALID, which the first rule would
# have sailed straight past. A rule that would not have caught the incident
# that motivated it is not a control.
#
# The all-zeros UUID below is the documented placeholder for tests. Any other
# genuinely non-secret UUID should either use that value or be added to an
# allowlist here, with a note saying why it is safe.

[extend]
useDefault = true

# Build output and dependencies. Neither is committed, but `gitleaks dir` walks
# the filesystem rather than the index and does not read .gitignore, so without
# this a local run reports Next.js's own generated prerender tokens as findings
# and buries anything real.
[allowlist]
description = "Uncommitted build output and vendored dependencies"
paths = [
'''^\.next/''',
'''^node_modules/''',
'''^\.git/''',
]

[[rules]]
id = "payreckon-uuid-credential-assignment"
description = "UUID assigned to an identifier naming a credential"
regex = '''(?i)[a-z0-9_]*(key|access|token|secret)[a-z0-9_]*["']?\s*[:=]\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}["']'''
tags = ["uuid", "credential"]

[rules.allowlist]
description = "Documented all-zeros placeholder"
regexes = ['''00000000-0000-4000-8000-000000000000''']

[[rules]]
id = "payreckon-uuid-literal"
description = "Bare UUID string literal in source, which may be a credential"
regex = '''(?i)["'][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}["']'''
tags = ["uuid"]

[rules.allowlist]
description = "Documented all-zeros placeholder, and dependency metadata"
regexes = ['''00000000-0000-4000-8000-000000000000''']
paths = ['''package-lock\.json''']
43 changes: 43 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Licence

Copyright (c) 2026 David Levi. All rights reserved.

This repository is published so that its source can be read and evaluated. It
is a demonstration of engineering practice, not an open source project, and no
licence to reuse it is granted.

## What you may do

You may view, read, and clone this repository for the purposes of reviewing the
code, evaluating the author's work, or private study.

## What you may not do

Without prior written permission from the copyright holder, you may not:

- copy, adapt, or create derivative works from any part of this repository
- deploy or host it, in whole or in part, publicly or privately
- redistribute it, or incorporate any part of it into another project
- use it for any commercial purpose

This applies to the calculation engine, the tax rate data and its citations,
the user interface, the tooling in `scripts/`, and the PayReckon brand assets
in `public/brand/`, which are trade marks of the author and are not covered by
any permission granted above.

## Absence of a licence file is not permission

Under GitHub's Terms of Service, publishing a repository without a licence
already reserves all rights. This file exists to state that position
explicitly, so that it reads as a decision rather than an oversight.

## No warranty

This software is provided "as is", without warranty of any kind, express or
implied. The calculators produce estimates for planning purposes and are not
financial, tax, or legal advice. The author accepts no liability for any claim,
damage, or other liability arising from its use.

## Getting permission

To ask about using any part of this work, contact david@invisionsolutions.co.uk.
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ behind all three, so the comparison is genuinely like for like.
> **Not financial advice.** PayReckon produces estimates for planning purposes.
> Always confirm your position with a qualified accountant before acting on it.

This repository is public so the engineering behind it can be read and
evaluated. It is not licensed for reuse: see [LICENSE.md](LICENSE.md).

## Calculators

| Calculator | Models |
Expand All @@ -23,11 +26,10 @@ postgraduate, three pension methods, Blind Person's and Marriage Allowance.
## What makes the numbers trustworthy

**The umbrella calculation is solved, not estimated.** Employer's NI, the
Apprenticeship Levy and employer pension are deducted *from* the assignment rate
but charged *on* the gross pay that remains — each depends on the other. Rather
than approximate, [`umbrella.ts`](lib/calculations/scenarios/umbrella.ts) solves
the relationship algebraically per NI branch, and a round-trip test asserts that
gross pay plus every employment cost returns the assignment rate exactly.
Apprenticeship Levy and employer pension are deducted from the assignment rate
but charged on the gross pay that remains, so each one depends on the others.
That circular dependency is solved exactly rather than approximated, and the
figures reconcile to the penny against the assignment rate they came from.

**One tax engine, three calculators.**
[`personalTax.ts`](lib/calculations/personalTax.ts) handles income tax, National
Expand Down Expand Up @@ -67,7 +69,7 @@ that breaks contrast cannot land quietly.
npm install
cp .env.example .env.local # then fill in the Web3Forms key (see below)
npm run dev # http://localhost:3000
npm test # 138 unit tests covering the tax engine
npm test # 155 unit tests covering the tax engine
npm run build # production build
npm run lint # eslint
npm run brand # regenerate every logo asset and the brand kit zip
Expand Down
53 changes: 53 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Security policy

## Reporting a vulnerability

Please report security issues privately to **david@invisionsolutions.co.uk**
rather than opening a public GitHub issue, so the problem can be fixed before
it is described publicly.

A useful report includes what you found, the steps to reproduce it, and what an
attacker could achieve with it. If you have a suggested fix, that is welcome
but not expected.

You can expect an acknowledgement within a few days. I will tell you whether
the issue is accepted, and once it is resolved I am happy to credit you unless
you would rather stay anonymous.

This is a personal project maintained by one person, so please treat these as
best efforts rather than a commercial support commitment.

## Scope

In scope:

- the deployed site at https://www.payreckon.co.uk
- anything in this repository, including the calculation engine and the
tooling in `scripts/`

Out of scope:

- vulnerabilities in Vercel, Web3Forms, or any other third party platform,
which should go to those vendors directly
- automated scanner output with no demonstrated impact
- missing headers or configuration that carry no exploitable consequence

Please do not run denial of service or automated load testing against the live
site.

## A note on the Web3Forms access key

The feedback form's access key is a publishable value. It is prefixed
`NEXT_PUBLIC_`, it is compiled into the client bundle by design, and it is
therefore visible to anyone viewing source. That is how Web3Forms works and is
not a vulnerability on its own. Submissions are restricted to this site's
domain in the Web3Forms dashboard.

If you find a way to abuse it despite that restriction, that is very much in
scope and worth reporting.

## Reporting an incorrect tax figure

A wrong number is not a security issue, but it is the thing I most want to hear
about. Use the [feedback form](https://www.payreckon.co.uk/feedback), and
include the inputs you used and the figure you expected.
6 changes: 6 additions & 0 deletions lib/calculations/scenarios/umbrella.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export interface EmploymentCosts {
*
* Both roots are computed and the one consistent with its own branch is taken, so
* the result is exact rather than iterated to a tolerance.
*
* The round trip in scenarios.test.ts is what holds this honest. It adds gross
* pay and every employment cost back together and asserts the total returns the
* assignment rate exactly, on both sides of the employer NI threshold, with
* employer pension in the mix, and for a zero-rate employer category. An
* approximation would drift and fail it.
*/
export function solveGrossPay(
availableForEmployment: number,
Expand Down
6 changes: 5 additions & 1 deletion lib/feedback/web3forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
normaliseAccessKey,
} from "./web3forms";

const VALID = "def7a146-5c36-43c5-8c76-b4ef38da296e";
// Deliberately a throwaway all-zeros UUID, never a real access key. The tests
// only need something UUID-shaped, so a live value buys nothing and would sit
// in this public repository's history permanently. Allowlisted in
// .gitleaks.toml so the UUID rules there do not fire on it.
const VALID = "00000000-0000-4000-8000-000000000000";

describe("normaliseAccessKey", () => {
it("accepts a clean key", () => {
Expand Down
65 changes: 64 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
import type { NextConfig } from "next";

/**
* Content Security Policy.
*
* Every asset this site serves is same origin. Fonts are self hosted by
* next/font at build time rather than fetched from Google, and all imagery is
* local, so nothing here needs a third party origin except the feedback form.
*
* connect-src is the directive that matters most: it is deliberately narrow,
* allowing exactly one external host, and it is what the /feedback page needs
* in order to reach Web3Forms. Narrowing it further, or forgetting to list
* that host, silently breaks form submission while every page still renders.
*
* script-src keeps 'unsafe-inline'. Removing it means a nonce, a nonce means
* middleware, and middleware would force every route to render dynamically,
* which would trade the site's fully static profile for a hardening win it
* cannot really bank while inline hydration data is still emitted. The
* directives that actually blunt injection here are object-src, base-uri,
* form-action and frame-ancestors, and those are all locked down.
*
* 'unsafe-eval' is development only, where the dev server needs it for hot
* reloading. It is never sent in production.
*/
const isDev = process.env.NODE_ENV === "development";

const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://api.web3forms.com",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
"manifest-src 'self'",
"upgrade-insecure-requests",
].join("; ");

const securityHeaders = [
{ key: "Content-Security-Policy", value: csp },
// frame-ancestors above covers modern browsers; this is the older equivalent.
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
// No preload token. Submitting to the browser preload list is effectively
// irreversible, and the header should not advertise an intent that has not
// been decided on. Adding it later is a one line change.
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains",
},
];

const nextConfig: NextConfig = {
/* config options here */
// Stops Next advertising itself in every response.
poweredByHeader: false,

async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
};

export default nextConfig;
Loading
Loading