Compare commits

...

4 Commits

Author SHA1 Message Date
c2e6fc8e77 Put the app behind a login
The server had no authentication: every /api route was open, CORS
allowed any origin, and the identity written to the audit log came from
the request body — the UI sent a hardcoded pf_user: 'admin', which any
client could have set to anything it liked.

Accounts live in pf.app_user with scrypt hashes from node's own crypto,
so there is no native build step and the parameters travel with each
hash. Sessions are express-session over connect-pg-simple in pf.session:
a restart no longer signs everyone out, and a session can be revoked by
deleting its row, which is how disable-user cuts off access immediately
rather than at cookie expiry.

Everything under /api except login/logout/me now requires a session, and
the React app is mounted only once there is one — its load effects call
the API on mount, so a logged-out mount would just fire a burst of 401s.
A session that expires while the app is open lands back on the login
screen: auth.jsx wraps fetch once rather than teaching every call site
to check.

Identity is now read from the session for pf_user, created_by and
closed_by, and the body values are ignored.

Hardened for an internet-facing deployment: trust proxy so req.ip and
secure-cookie detection are right behind TLS termination, httpOnly +
SameSite=Lax + Secure cookies, ten login failures per IP per fifteen
minutes, one error message for unknown, wrong and disabled alike, and a
fresh session id on success. CORS is off entirely unless CORS_ORIGIN
names an origin — a wildcard alongside a session cookie would be CSRF by
construction. The server refuses to boot without SESSION_SECRET rather
than falling back to a guessable default.

pf.sh grows add-user, passwd, list-users, disable-user and enable-user;
passwords are read on stdin and hashed before they reach psql, so no
plaintext in argv or shell history. install.sh generates the secret,
applies 02_auth.sql, and creates the first account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:20:25 -04:00
146961cc17 Point pf.sh at the DB_* env vars the server actually reads
server.js builds its pg pool from DB_HOST/DB_PORT/DB_NAME/DB_USER/
DB_PASSWORD, and that is what install.sh writes, but pf.sh had been
written against a DATABASE_URL/PF_USER scheme that nothing consumes.
The consequences were real: `status` always reported the database
unreachable, and `config` rewrote .env with cat >, replacing a working
connection with keys the server ignores.

Connect through a run_psql helper built from the DB_* vars, and have
config prompt for those six keys instead. Each prompt defaults to the
current value so entering through changes nothing, the password prompt
is hidden and keeps the stored one when left blank, and any other key
already in .env is carried over rather than dropped.

PF_USER goes away with it — pf_user reaches the server in the request
body, never from the environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:08:49 -04:00
b1eb68a475 Vendor a patched Perspective build with column-axis expand/collapse
Perspective's column axis cannot be collapsed. The row axis has had it
forever — GROUP BY ROLLUP holds every level and view.set_depth() hides the
deeper ones — but nothing equivalent is exposed for split_by, so a Year over
Month pivot can only ever be shown fully expanded.

The engine already implements it. t_ctx2 is symmetric: set_depth(t_header,
depth), open(t_header, idx) and close(t_header, idx) each have a real
HEADER_COLUMN branch on m_ctraversal mirroring m_rtraversal, and
t_view_config carries m_column_pivot_depth which server.cpp already applies.
None of it is reachable: set_column_pivot_depth() is never called, so the
depth stays -1, and View<t_ctx2>::expand/collapse hardcode HEADER_ROW. The
patch is 193 lines of wiring across the protobuf, the Rust client and the
datagrid — no new engine logic.

Two capabilities result, mirroring the row axis:

- split_by_depth in ViewConfig, the split_by counterpart to group_by_depth
- expand_column()/collapse_column(), addressed by column traversal index
  exactly as the row methods are addressed by row index

which together give the Excel behaviour — one year folded to its subtotal
while its siblings stay expanded — that no combination of existing config
could produce. Verified in this app against fc_cash_9: clicking a Year
header goes from 27 columns to 15, totals reconciling at every level.

Vendored rather than aliased
- The previous approach pointed vite at a local checkout, which built only on
  one laptop and left package.json claiming npm 5.2.0 while the build used
  something else. The four packages are now committed as npm tarballs and
  package.json names them, so the declaration is true and `pf.sh deploy`
  works unchanged — npm install expands them like any registry package.
- Packed with `pnpm pack`, not `npm pack`: Perspective is a pnpm workspace and
  cross-package deps are `workspace:^`, which npm rejects outright. pnpm
  rewrites those to real version ranges at pack time.
- All four move together. Perspective couples loader, package versions, data
  format and apache-arrow; a partial vendor reintroduces exactly that drift.

Cost, stated plainly: 12MB of opaque binaries in git that do not delta, a
fork to maintain, and a second engine build for anyone changing it.
rebuild-perspective.sh makes that repeatable and PROVENANCE.txt records the
commit each tarball came from, because a committed .tgz otherwise has no
recoverable source. README.md says how to delete all of it once upstream
ships the feature.

This also moves the app from 5.2.0 to 5.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
2026-09-13 22:42:05 -04:00
4b9296abc1 Collapse the column hierarchy from the toolbar
The row axis has had Expand 0/1/2/3 since the pivot landed; the column
axis had nothing. With a year over month split there was no way to step
back to whole years short of dragging split_by apart in the settings
panel and putting it back afterwards.

Perspective gives the two axes nothing in common here. Rows collapse
because the GROUP BY ROLLUP view holds every level at once and
view.set_depth() hides the deeper ones. For columns there is no
equivalent: expand()/collapse() take a row index, ViewConfig has
group_by_depth but no split_by_depth, and split_rollup_mode only chooses
whether subtotal column groups are emitted — a view shape, not an
interaction. So applySplitDepth() collapses by restoring a truncated
split_by, which rebuilds the view.

Three consequences of that rebuild, each handled:

- Once collapsed, viewer.save() only reports the short split_by, so the
  full hierarchy is held separately (splitFullRef) and persisted into the
  layout as split_full. Without it, collapsing would be a one-way door:
  reload while collapsed and the deeper levels are gone. adoptSplit() is
  the single place it is set.
- perspective-config-update fires for our own restore as well as for the
  user rearranging the pivot, and the two mean opposite things — one must
  adopt the new hierarchy, the other must not. collapsingRef separates
  them.
- Row depth lives on the discarded view, so it is re-applied afterwards.

The selection is cleared on each change: slices name the split_by
dimensions they were cut from, and the highlight is keyed on grid
coordinates. Neither survives a column axis that just changed shape.

Buttons are named for the level they show — Total, then one per split_by
column — rather than numbered like Expand, since the levels are named and
a number would say nothing about what you are collapsing to.

Whole-axis, not per-branch: Excel can collapse 2025 while 2026 stays
expanded, and this cannot. `columns` selects which measures appear, not
individual split combinations, so there is no way to hide one branch's
leaves while keeping another's.

Verified in the browser against cash/test with split_by Year x Reason:
Reason -> Total -> Year -> Reason all render the expected column sets and
the right button highlights; and a reload while collapsed to Year comes
back collapsed with Reason still offered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
2026-09-12 09:15:15 -04:00
28 changed files with 1125 additions and 78 deletions

View File

@ -4,3 +4,18 @@ DB_NAME=your_database
DB_USER=your_user DB_USER=your_user
DB_PASSWORD=your_password DB_PASSWORD=your_password
PORT=3010 PORT=3010
# Signs the session cookie. Generate with:
# node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
# or let ./pf.sh config do it. Changing it signs everyone out.
SESSION_SECRET=
# Send the session cookie over HTTPS only. Keep true behind a TLS proxy;
# set false only to reach the app over plain HTTP on a trusted network.
COOKIE_SECURE=true
# Reverse proxy hops express should trust for req.ip / protocol. Default 1.
#TRUST_PROXY=1
# Only needed if the UI is served from a different origin than the API.
#CORS_ORIGIN=https://forecast.example.com

View File

@ -22,8 +22,9 @@ Data transport architecture options: `pf_perspective_options.md`
## Project layout ## Project layout
``` ```
server.js Express entry point; pg pool; type parsers for bigint/numeric server.js Express entry point; pg pool; session; type parsers for bigint/numeric
routes/ routes/
auth.js POST /api/login, /api/logout, GET /api/me; login throttle
tables.js GET /api/tables, /api/tables/:schema/:tname/preview tables.js GET /api/tables, /api/tables/:schema/:tname/preview
sources.js Source registration, col_meta, SQL generation sources.js Source registration, col_meta, SQL generation
versions.js Version CRUD, baseline/reference load, data stream versions.js Version CRUD, baseline/reference load, data stream
@ -31,11 +32,15 @@ routes/
log.js GET /api/versions/:id/log, DELETE /api/log/:logid log.js GET /api/versions/:id/log, DELETE /api/log/:logid
lib/ lib/
sql_generator.js buildFilterClause, token substitution helpers sql_generator.js buildFilterClause, token substitution helpers
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
utils.js utils.js
setup_sql/ setup_sql/
01_schema.sql pf schema DDL — run once to install 01_schema.sql pf schema DDL — run once to install
02_auth.sql pf.app_user + pf.session
ui/src/ ui/src/
auth.jsx AuthProvider/useAuth; wraps fetch so any 401 returns to login
views/ views/
Login.jsx Sign-in form
Setup.jsx DB browser, source registration, col_meta editor Setup.jsx DB browser, source registration, col_meta editor
Baseline.jsx Version management, baseline workbench, reference load Baseline.jsx Version management, baseline workbench, reference load
Forecast.jsx Perspective pivot, selection handling, operation dispatch Forecast.jsx Perspective pivot, selection handling, operation dispatch
@ -57,6 +62,8 @@ ui/src/
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`) - **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`)
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb - **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time - **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
- **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
- **`pf.session`** — express-session store (connect-pg-simple layout)
- **`pf.dim_period`** — calendar lookup table (20182035); one row per month keyed on `sdat` (month start date); provides cal/fiscal year, quarter, and month columns; populated by `setup_sql/gen_dim_period.sql` with a configurable fiscal year start month - **`pf.dim_period`** — calendar lookup table (20182035); one row per month keyed on `sdat` (month start date); provides cal/fiscal year, quarter, and month columns; populated by `setup_sql/gen_dim_period.sql` with a configurable fiscal year start month
### Key token substitution tokens ### Key token substitution tokens
@ -95,6 +102,43 @@ Turning a region back into slices re-derives, per cell, the same filters Perspec
--- ---
## Column hierarchy (collapse / expand)
The two pivot axes collapse by completely different mechanisms, and the asymmetry is a
Perspective constraint, not a choice:
- **Rows.** The `GROUP BY ROLLUP` view holds every level at once; `view.set_depth()` — which
lives on the view, not the config — hides the deeper ones. That is what the `EXPAND 0 1 2 3`
buttons drive, via `applyDepth()`.
- **Columns.** There is no equivalent. `expand()` / `collapse()` take a **row index**,
`ViewConfig` has `group_by_depth` but no `split_by_depth`, and `split_rollup_mode`
(`'flat' | 'rollup'`) only chooses whether subtotal column groups are *emitted* — it is a
view shape, not an interaction. So `applySplitDepth(n)` collapses by restoring a
**truncated `split_by`**, which rebuilds the view.
Three things follow from the rebuild, and each is handled:
1. The full hierarchy has to be remembered separately — once collapsed, `viewer.save()`
only reports the short `split_by`. `splitFullRef` / `splitFull` hold it, and it is
persisted into the saved layout as `split_full` so a reload while collapsed can still
expand back. `adoptSplit()` is the single place it is set.
2. `perspective-config-update` fires for our own restore as well as the user rearranging
the pivot. `collapsingRef` distinguishes them — without it, a collapse would overwrite
the full hierarchy with the truncated one and the deeper levels would be unreachable.
3. Row depth lives on the discarded view, so `applyDepth(expandDepthRef.current)` is
re-applied afterwards — the same wart as the refocus re-apply.
The selection is cleared on every change: slices name the split_by dimensions they were
cut from, and the highlight is keyed on grid coordinates. Neither survives a column axis
that just changed shape.
**Limitation:** this is whole-axis, not per-branch. Excel can collapse 2025 while 2026
stays expanded; truncating `split_by` collapses every column group at that level together.
Per-branch is not reachable — `columns` selects which *measures* appear, not individual
split combinations.
---
## Operation SQL patterns ## Operation SQL patterns
All three operations follow the same structure: insert a `pf.log` row in a CTE, then insert forecast rows referencing its id. `{{where_clause}}` is built from the slice; `{{exclude_clause}}` blocks `exclude_iters` rows. All three operations follow the same structure: insert a `pf.log` row in a CTE, then insert forecast rows referencing its id. `{{where_clause}}` is built from the slice; `{{exclude_clause}}` blocks `exclude_iters` rows.
@ -107,6 +151,33 @@ All three operations follow the same structure: insert a `pf.log` row in a CTE,
--- ---
## Authentication
Everything under `/api` except the auth routes sits behind a session; the React
app is only mounted once there is one (`Gate` in `main.jsx`), because its load
effects call the API immediately.
- **Accounts:** `pf.app_user` — scrypt hashes from `lib/auth.js`, never plaintext.
Managed with `./pf.sh add-user | passwd | list-users | disable-user | enable-user`;
the password is read on stdin and hashed before it reaches psql.
- **Sessions:** `express-session` + `connect-pg-simple` in `pf.session`, so a
restart doesn't sign everyone out and a session can be revoked by deleting its
row (`disable-user` does exactly that). Cookie `pf.sid`: httpOnly, SameSite=Lax,
Secure unless `COOKIE_SECURE=false`, 12h rolling.
- **Config:** `SESSION_SECRET` is required — the server exits at boot without one.
`TRUST_PROXY` (default 1) makes `req.ip` and secure-cookie detection correct
behind the TLS proxy. `CORS_ORIGIN` is the only way CORS is enabled at all; a
wildcard origin plus a session cookie would be cross-site request forgery by
construction.
- **Login hardening:** `routes/auth.js` throttles to 10 failures per IP per 15
minutes (in-memory), returns one message for unknown/wrong/disabled alike, and
regenerates the session id on success.
**Identity is server-side.** `pf_user`, `created_by` and `closed_by` come from
`sessionUser(req)`, never from the request body — the UI used to send a hardcoded
`pf_user: 'admin'`, which any client could have set to anything. The audit log
now names the account that made the change.
## Light / dark mode ## Light / dark mode
Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`. Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`.

View File

@ -26,6 +26,12 @@ echo ""
read -p "App port [3030]: " PORT read -p "App port [3030]: " PORT
PORT=${PORT:-3030} PORT=${PORT:-3030}
# Session cookies are signed with this; the server refuses to start without it.
SESSION_SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
read -p "Send session cookie over HTTPS only? [Y/n]: " SECURE_ANS
case "${SECURE_ANS:-y}" in [Nn]*) COOKIE_SECURE=false ;; *) COOKIE_SECURE=true ;; esac
# ── Write .env ──────────────────────────────────────────────── # ── Write .env ────────────────────────────────────────────────
cat > .env <<EOF cat > .env <<EOF
DB_HOST=${DB_HOST} DB_HOST=${DB_HOST}
@ -34,7 +40,10 @@ DB_NAME=${DB_NAME}
DB_USER=${DB_USER} DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD} DB_PASSWORD=${DB_PASSWORD}
PORT=${PORT} PORT=${PORT}
SESSION_SECRET=${SESSION_SECRET}
COOKIE_SECURE=${COOKIE_SECURE}
EOF EOF
chmod 600 .env
echo "✓ .env written" echo "✓ .env written"
# ── npm install ─────────────────────────────────────────────── # ── npm install ───────────────────────────────────────────────
@ -51,15 +60,23 @@ PGPASSWORD=${DB_PASSWORD} psql \
-p "${DB_PORT}" \ -p "${DB_PORT}" \
-U "${DB_USER}" \ -U "${DB_USER}" \
-d "${DB_NAME}" \ -d "${DB_NAME}" \
-f setup_sql/01_schema.sql -v ON_ERROR_STOP=1 \
-f setup_sql/01_schema.sql \
-f setup_sql/02_auth.sql
echo "✓ schema installed" echo "✓ schema installed"
# ── first account ─────────────────────────────────────────────
echo ""
echo "The app is behind a login. Create the first account now:"
./pf.sh add-user
# ── done ───────────────────────────────────────────────────── # ── done ─────────────────────────────────────────────────────
echo "" echo ""
echo "========================================" echo "========================================"
echo " Install complete" echo " Install complete"
echo " Start with: npm run dev" echo " Start with: npm run dev
More accounts: ./pf.sh add-user"
echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}" echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "========================================" echo "========================================"
echo "" echo ""

70
lib/auth.js Normal file
View File

@ -0,0 +1,70 @@
// Password hashing and the route guard.
//
// Hashes are scrypt, from node's own crypto — no native build step, and the
// stored form carries its own parameters so they can be raised later without
// invalidating existing rows:
//
// scrypt$<N>$<r>$<p>$<salt base64>$<derived key base64>
const crypto = require('crypto');
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
function hashPassword(password, params = SCRYPT) {
const { N, r, p, keylen } = params;
const salt = crypto.randomBytes(16);
const dk = crypto.scryptSync(password, salt, keylen, { N, r, p, maxmem: 256 * 1024 * 1024 });
return `scrypt$${N}$${r}$${p}$${salt.toString('base64')}$${dk.toString('base64')}`;
}
// Constant-time compare. Returns false rather than throwing on a malformed or
// legacy hash, so one bad row can't 500 the login route.
function verifyPassword(password, stored) {
if (typeof stored !== 'string') return false;
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const [, N, r, p, saltB64, dkB64] = parts;
try {
const salt = Buffer.from(saltB64, 'base64');
const expected = Buffer.from(dkB64, 'base64');
const actual = crypto.scryptSync(password, salt, expected.length, {
N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024,
});
return crypto.timingSafeEqual(actual, expected);
} catch {
return false;
}
}
// Every /api route except the auth ones sits behind this.
function requireAuth(req, res, next) {
if (req.session?.user?.username) return next();
res.status(401).json({ error: 'Not authenticated' });
}
// The identity used for pf.log.pf_user and the created_by/closed_by columns.
// Read from the session only — never from the request body, which the browser
// controls and which used to carry a hardcoded 'admin'.
function sessionUser(req) {
return req.session?.user?.username || null;
}
module.exports = { hashPassword, verifyPassword, requireAuth, sessionUser, SCRYPT };
// CLI: `node lib/auth.js hash` reads a password on stdin and prints its hash,
// so ./pf.sh can create users without the plaintext touching argv or psql.
if (require.main === module) {
if (process.argv[2] !== 'hash') {
console.error('usage: node lib/auth.js hash (password on stdin)');
process.exit(2);
}
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
const password = input.replace(/\r?\n$/, '');
if (!password) { console.error('empty password'); process.exit(2); }
process.stdout.write(hashPassword(password) + '\n');
});
}

69
package-lock.json generated
View File

@ -7,11 +7,14 @@
"": { "": {
"name": "pf_app", "name": "pf_app",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT",
"dependencies": { "dependencies": {
"apache-arrow": "^21.1.0", "apache-arrow": "^21.1.0",
"connect-pg-simple": "^10.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.19.0",
"pg": "^8.11.3" "pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {
@ -369,6 +372,18 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/connect-pg-simple": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
"integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==",
"license": "MIT",
"dependencies": {
"pg": "^8.12.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=22.0.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -582,6 +597,29 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -1085,6 +1123,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1105,6 +1152,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@ -1276,6 +1324,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -1592,6 +1649,18 @@
"node": ">=12.17" "node": ">=12.17"
} }
}, },
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",

View File

@ -11,9 +11,11 @@
}, },
"dependencies": { "dependencies": {
"apache-arrow": "^21.1.0", "apache-arrow": "^21.1.0",
"connect-pg-simple": "^10.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.19.0",
"pg": "^8.11.3" "pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {

257
pf.sh
View File

@ -4,6 +4,7 @@ set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# pf.sh — Pivot Forecast management script # pf.sh — Pivot Forecast management script
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config] # Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
# ./pf.sh [add-user|passwd|list-users|disable-user|enable-user]
# ./pf.sh (interactive menu) # ./pf.sh (interactive menu)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -67,13 +68,33 @@ require_service() {
service_installed || die "systemd service not installed. Run: ./pf.sh install-service" service_installed || die "systemd service not installed. Run: ./pf.sh install-service"
} }
# The keys cmd_config manages; anything else in .env is left alone.
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT SESSION_SECRET COOKIE_SECURE)
env_get() {
[[ -f "$ENV_FILE" ]] || return 0
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' || true
}
# psql against the DB_* connection in .env; extra args are passed through.
run_psql() {
PGPASSWORD="${DB_PASSWORD:-}" psql \
-h "${DB_HOST:-localhost}" \
-p "${DB_PORT:-5432}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
"$@"
}
db_ping() { db_ping() {
load_env load_env
local url="${DATABASE_URL:-}" if [[ -z "${DB_NAME:-}" || -z "${DB_USER:-}" ]]; then
[[ -z "$url" ]] && { warn "DATABASE_URL not set in .env"; return 1; } warn "DB_NAME / DB_USER not set in .env"
return 1
fi
# Use psql if available for a real connectivity check # Use psql if available for a real connectivity check
if command -v psql &>/dev/null; then if command -v psql &>/dev/null; then
psql "$url" -c "SELECT 1" &>/dev/null && return 0 || return 1 run_psql -tAc "SELECT 1" &>/dev/null && return 0 || return 1
else else
warn "psql not in PATH — skipping live DB check" warn "psql not in PATH — skipping live DB check"
return 0 return 0
@ -168,18 +189,25 @@ cmd_logs() {
cmd_db_setup() { cmd_db_setup() {
require_env; load_env require_env; load_env
local url="${DATABASE_URL:-}" [[ -n "${DB_NAME:-}" && -n "${DB_USER:-}" ]] || die "DB_NAME / DB_USER not set in .env — run: ./pf.sh config"
[[ -z "$url" ]] && die "DATABASE_URL not set in .env"
command -v psql &>/dev/null || die "psql not found — install postgresql-client" command -v psql &>/dev/null || die "psql not found — install postgresql-client"
echo echo
bold "DB Setup — will run: setup_sql/01_schema.sql" bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
warn "This creates the pf schema and tables. Safe to re-run (CREATE IF NOT EXISTS)." warn "This creates the pf schema, tables, and the account/session tables."
warn "Safe to re-run (CREATE IF NOT EXISTS)."
read -rp " Continue? [y/N] " confirm read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; } [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
psql "$url" -f "${APP_DIR}/setup_sql/01_schema.sql" run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
success "Schema applied." success "Schema applied."
local n
n=$(run_psql -tAc "SELECT count(*) FROM pf.app_user WHERE is_active" 2>/dev/null || echo 0)
if [[ "${n:-0}" == "0" ]]; then
warn "No active accounts yet — create one with: ./pf.sh add-user"
fi
} }
cmd_config() { cmd_config() {
@ -188,41 +216,193 @@ cmd_config() {
echo " File: $ENV_FILE" echo " File: $ENV_FILE"
echo echo
local current_url="" local cur_host cur_port cur_name cur_user cur_pass cur_app_port
local current_port="" cur_host=$(env_get DB_HOST)
local current_user="" cur_port=$(env_get DB_PORT)
cur_name=$(env_get DB_NAME)
cur_user=$(env_get DB_USER)
cur_pass=$(env_get DB_PASSWORD)
cur_app_port=$(env_get PORT)
if [[ -f "$ENV_FILE" ]]; then local input
current_url=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | cut -d= -f2- | tr -d '"' || true) read -rp " DB_HOST [${cur_host:-localhost}]: " input
current_port=$(grep -E '^PORT=' "$ENV_FILE" | cut -d= -f2- | tr -d '"' || true) local host="${input:-${cur_host:-localhost}}"
current_user=$(grep -E '^PF_USER=' "$ENV_FILE" | cut -d= -f2- | tr -d '"' || true)
read -rp " DB_PORT [${cur_port:-5432}]: " input
local port="${input:-${cur_port:-5432}}"
read -rp " DB_NAME [${cur_name:-not set}]: " input
local name="${input:-$cur_name}"
[[ -z "$name" ]] && die "DB_NAME is required."
read -rp " DB_USER [${cur_user:-$USER}]: " input
local user="${input:-${cur_user:-$USER}}"
if [[ -n "$cur_pass" ]]; then
read -rsp " DB_PASSWORD [keep existing]: " input; echo
else
read -rsp " DB_PASSWORD: " input; echo
fi
local pass="${input:-$cur_pass}"
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
local app_port="${input:-${cur_app_port:-3010}}"
# Session cookies are signed with this; regenerating it signs everyone out,
# so an existing secret is kept rather than re-rolled on every config run.
local secret
secret=$(env_get SESSION_SECRET)
if [[ -z "$secret" ]]; then
secret=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
success "SESSION_SECRET generated."
else
success "SESSION_SECRET kept (delete the line in .env to re-roll)."
fi fi
read -rp " DATABASE_URL [${current_url:-not set}]: " input_url local cur_secure
local url="${input_url:-$current_url}" cur_secure=$(env_get COOKIE_SECURE)
[[ -z "$url" ]] && die "DATABASE_URL is required." read -rp " COOKIE_SECURE — HTTPS-only cookie [${cur_secure:-true}]: " input
local cookie_secure="${input:-${cur_secure:-true}}"
read -rp " PORT [${current_port:-3010}]: " input_port # Rewrite the managed keys, carrying over any other lines already in .env.
local port="${input_port:-${current_port:-3010}}" local tmp
tmp=$(mktemp)
read -rp " PF_USER [${current_user:-$USER}]: " input_user cat > "$tmp" <<EOF
local pf_user="${input_user:-${current_user:-$USER}}" DB_HOST=${host}
DB_PORT=${port}
cat > "$ENV_FILE" <<EOF DB_NAME=${name}
DATABASE_URL=${url} DB_USER=${user}
PORT=${port} DB_PASSWORD=${pass}
PF_USER=${pf_user} PORT=${app_port}
SESSION_SECRET=${secret}
COOKIE_SECURE=${cookie_secure}
EOF EOF
if [[ -f "$ENV_FILE" ]]; then
local managed
managed=$(IFS='|'; echo "${ENV_KEYS[*]}")
grep -vE "^(${managed})=" "$ENV_FILE" | grep -vE '^[[:space:]]*$' >> "$tmp" || true
fi
mv "$tmp" "$ENV_FILE"
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
success ".env written." success ".env written."
if db_ping; then if db_ping; then
success "Database connection verified." success "Database connection verified."
else else
warn "Could not reach the database — double-check DATABASE_URL." warn "Could not reach the database — double-check the DB_* settings."
fi fi
} }
# -- Accounts ----------------------------------------------------------------
# Reads a password twice without echo and hashes it with lib/auth.js, so the
# plaintext never reaches argv, psql, or the shell history.
read_new_password() {
local p1 p2
# Prompts and their newlines go to stderr: stdout is the hash, and a stray
# newline there ends up prefixed to it by the caller's $( ).
read -rsp " Password: " p1; echo >&2
[[ -z "$p1" ]] && { error "Password cannot be empty."; return 1; }
read -rsp " Confirm : " p2; echo >&2
[[ "$p1" != "$p2" ]] && { error "Passwords do not match."; return 1; }
printf '%s' "$p1" | node "${APP_DIR}/lib/auth.js" hash
}
# psql single-quoted literal: double any embedded quote.
sql_lit() { printf "%s" "${1//\'/\'\'}"; }
cmd_add_user() {
require_env; load_env
check_node >/dev/null
echo; bold "Add account"
local username display hash
read -rp " Username: " username
[[ -z "$username" ]] && die "Username is required."
read -rp " Display name [${username}]: " display
display="${display:-$username}"
hash=$(read_new_password) || return 1
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH ins AS (
INSERT INTO pf.app_user (username, pass_hash, display_name)
VALUES ('$(sql_lit "$username")', '$(sql_lit "$hash")', '$(sql_lit "$display")')
ON CONFLICT (username) DO NOTHING
RETURNING id
) SELECT id FROM ins" | grep -q . \
&& success "Account '${username}' created." \
|| die "Account '${username}' already exists — change its password with: ./pf.sh passwd"
}
cmd_passwd() {
require_env; load_env
check_node >/dev/null
echo; bold "Change password"
local username hash
read -rp " Username: " username
[[ -z "$username" ]] && die "Username is required."
hash=$(read_new_password) || return 1
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH upd AS (
UPDATE pf.app_user SET pass_hash = '$(sql_lit "$hash")'
WHERE lower(username) = lower('$(sql_lit "$username")')
RETURNING id
) SELECT id FROM upd" | grep -q . \
&& success "Password updated for '${username}'." \
|| die "No such account: ${username}"
}
cmd_list_users() {
require_env; load_env
echo; bold "Accounts"
run_psql -c "
SELECT username, display_name, is_active,
to_char(last_login_at, 'YYYY-MM-DD HH24:MI') AS last_login
FROM pf.app_user ORDER BY username"
}
# Deactivating leaves the row (and its history) in place, and drops any live
# session so the account loses access immediately rather than at cookie expiry.
cmd_disable_user() {
require_env; load_env
local username="${1:-}"
[[ -z "$username" ]] && { read -rp " Username to disable: " username; }
[[ -z "$username" ]] && die "Username is required."
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH upd AS (
UPDATE pf.app_user SET is_active = false
WHERE lower(username) = lower('$(sql_lit "$username")')
RETURNING id
) SELECT id FROM upd" | grep -q . \
|| die "No such account: ${username}"
run_psql -v ON_ERROR_STOP=1 -c "
DELETE FROM pf.session
WHERE sess::jsonb -> 'user' ->> 'username' ILIKE '$(sql_lit "$username")'" >/dev/null
success "Account '${username}' disabled and signed out."
}
cmd_enable_user() {
require_env; load_env
local username="${1:-}"
[[ -z "$username" ]] && { read -rp " Username to enable: " username; }
[[ -z "$username" ]] && die "Username is required."
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH upd AS (
UPDATE pf.app_user SET is_active = true
WHERE lower(username) = lower('$(sql_lit "$username")')
RETURNING id
) SELECT id FROM upd" | grep -q . \
&& success "Account '${username}' enabled." \
|| die "No such account: ${username}"
}
cmd_install_service() { cmd_install_service() {
require_systemd require_systemd
require_env require_env
@ -303,9 +483,14 @@ interactive_menu() {
echo " 5) status service + DB + git info" echo " 5) status service + DB + git info"
echo " 6) logs tail journald logs" echo " 6) logs tail journald logs"
echo " 7) db-setup apply setup_sql/01_schema.sql" echo " 7) db-setup apply setup_sql/01_schema.sql"
echo " 8) config set DATABASE_URL / PORT / PF_USER" echo " 8) config set DB connection + app PORT"
echo " 9) install-service create systemd unit file" echo " 9) install-service create systemd unit file"
echo " 10) uninstall-service remove systemd unit file" echo " 10) uninstall-service remove systemd unit file"
echo " 11) add-user create a login account"
echo " 12) passwd change an account password"
echo " 13) list-users show accounts"
echo " 14) disable-user deactivate an account and sign it out"
echo " 15) enable-user reactivate an account"
echo " q) quit" echo " q) quit"
echo echo
read -rp " Choice: " choice read -rp " Choice: " choice
@ -320,6 +505,11 @@ interactive_menu() {
8|config) cmd_config ;; 8|config) cmd_config ;;
9|install-service) cmd_install_service ;; 9|install-service) cmd_install_service ;;
10|uninstall-service) cmd_uninstall_service ;; 10|uninstall-service) cmd_uninstall_service ;;
11|add-user) cmd_add_user ;;
12|passwd) cmd_passwd ;;
13|list-users) cmd_list_users ;;
14|disable-user) cmd_disable_user ;;
15|enable-user) cmd_enable_user ;;
q|Q|quit|exit) echo "Bye."; exit 0 ;; q|Q|quit|exit) echo "Bye."; exit 0 ;;
*) warn "Unknown option: $choice" ;; *) warn "Unknown option: $choice" ;;
esac esac
@ -339,6 +529,11 @@ case "${1:-}" in
config) cmd_config ;; config) cmd_config ;;
install-service) cmd_install_service ;; install-service) cmd_install_service ;;
uninstall-service) cmd_uninstall_service ;; uninstall-service) cmd_uninstall_service ;;
add-user) cmd_add_user ;;
passwd) cmd_passwd ;;
list-users) cmd_list_users ;;
disable-user) cmd_disable_user "${2:-}" ;;
enable-user) cmd_enable_user "${2:-}" ;;
"") interactive_menu ;; "") interactive_menu ;;
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service" ;; *) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service add-user passwd list-users disable-user enable-user" ;;
esac esac

107
routes/auth.js Normal file
View File

@ -0,0 +1,107 @@
const express = require('express');
const { verifyPassword } = require('../lib/auth');
// Per-IP login throttle. In-memory on purpose: it only has to blunt online
// guessing, and a counter that resets on restart is the acceptable cost of not
// writing a failed-attempt row for every knock on an internet-facing port.
const WINDOW_MS = 15 * 60 * 1000;
const MAX_ATTEMPTS = 10;
const attempts = new Map(); // ip -> { count, resetAt }
function tooManyAttempts(ip) {
const rec = attempts.get(ip);
if (!rec || Date.now() > rec.resetAt) return false;
return rec.count >= MAX_ATTEMPTS;
}
function recordFailure(ip) {
const rec = attempts.get(ip);
if (!rec || Date.now() > rec.resetAt) {
attempts.set(ip, { count: 1, resetAt: Date.now() + WINDOW_MS });
} else {
rec.count += 1;
}
}
// Keep the map from growing without bound on a long-lived process.
setInterval(() => {
const now = Date.now();
for (const [ip, rec] of attempts) if (now > rec.resetAt) attempts.delete(ip);
}, WINDOW_MS).unref();
module.exports = function(pool) {
const router = express.Router();
router.post('/login', async (req, res) => {
const ip = req.ip;
if (tooManyAttempts(ip)) {
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
}
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
try {
const result = await pool.query(
`SELECT id, username, display_name, pass_hash, is_active
FROM pf.app_user WHERE lower(username) = lower($1)`,
[username]
);
const user = result.rows[0];
// Same message and roughly the same work either way: no unknown
// user / wrong password / disabled distinction to enumerate.
const ok = user && user.is_active && verifyPassword(password, user.pass_hash);
if (!ok) {
recordFailure(ip);
return res.status(401).json({ error: 'Invalid username or password' });
}
// New session id on login — an existing cookie can't be fixated.
req.session.regenerate(err => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Could not start session' });
}
req.session.user = {
id: user.id,
username: user.username,
display_name: user.display_name,
};
pool.query(`UPDATE pf.app_user SET last_login_at = now() WHERE id = $1`, [user.id])
.catch(e => console.error('last_login_at update failed', e));
req.session.save(err2 => {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Could not start session' });
}
attempts.delete(ip);
res.json({ user: req.session.user });
});
});
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
router.post('/logout', (req, res) => {
const name = req.session?.cookie && req.app.get('session cookie name');
req.session.destroy(err => {
if (err) console.error(err);
res.clearCookie(name || 'pf.sid');
res.json({ ok: true });
});
});
// The UI calls this on load to decide between the login screen and the app.
router.get('/me', (req, res) => {
if (!req.session?.user) return res.status(401).json({ error: 'Not authenticated' });
res.json({ user: req.session.user });
});
return router;
};

View File

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow'); const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator'); const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
const { sessionUser } = require('../lib/auth');
const { fcTable } = require('../lib/utils'); const { fcTable } = require('../lib/utils');
module.exports = function(pool) { module.exports = function(pool) {
@ -304,7 +305,8 @@ module.exports = function(pool) {
// load baseline rows from source table — additive, no delete // load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => { router.post('/versions/:id/baseline', async (req, res) => {
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body; const { where_clause, date_offset, note, filters, raw_where } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days'; const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE'; const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try { try {
@ -339,7 +341,8 @@ module.exports = function(pool) {
router.put('/versions/:id/baseline/:logid', async (req, res) => { router.put('/versions/:id/baseline/:logid', async (req, res) => {
const versionId = parseInt(req.params.id); const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid); const logid = parseInt(req.params.logid);
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body; const { where_clause, date_offset, note, filters, raw_where } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days'; const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE'; const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
@ -445,7 +448,8 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows) // load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => { router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body; const { where_clause, date_offset, note, filters, raw_where } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days'; const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE'; const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try { try {
@ -478,7 +482,8 @@ module.exports = function(pool) {
// target or by an increment. With several slices selected, apply_mode decides // target or by an increment. With several slices selected, apply_mode decides
// whether they are treated as one pool ('prorate') or independently ('each'). // whether they are treated as one pool ('prorate') or independently ('each').
router.post('/versions/:id/scale', async (req, res) => { router.post('/versions/:id/scale', async (req, res) => {
const { pf_user, note, apply_mode } = req.body; const { note, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body); const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
@ -565,7 +570,8 @@ module.exports = function(pool) {
// recode dimension values on one or more slices // recode dimension values on one or more slices
// inserts negative rows to zero out the original, positive rows with new dimension values // inserts negative rows to zero out the original, positive rows with new dimension values
router.post('/versions/:id/recode', async (req, res) => { router.post('/versions/:id/recode', async (req, res) => {
const { pf_user, note, set, apply_mode } = req.body; const { note, set, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body); const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
@ -618,7 +624,8 @@ module.exports = function(pool) {
// clone one or more slices as new business under new dimension values // clone one or more slices as new business under new dimension values
// does not offset the original slice // does not offset the original slice
router.post('/versions/:id/clone', async (req, res) => { router.post('/versions/:id/clone', async (req, res) => {
const { pf_user, note, set, scale, apply_mode } = req.body; const { note, set, scale, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body); const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });

View File

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { generateSQL } = require('../lib/sql_generator'); const { generateSQL } = require('../lib/sql_generator');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) { module.exports = function(pool) {
const router = express.Router(); const router = express.Router();
@ -20,7 +21,8 @@ module.exports = function(pool) {
// register a source table // register a source table
// auto-populates col_meta from information_schema with role='ignore' // auto-populates col_meta from information_schema with role='ignore'
router.post('/sources', async (req, res) => { router.post('/sources', async (req, res) => {
const { schema, tname, label, created_by } = req.body; const { schema, tname, label } = req.body;
const created_by = sessionUser(req);
if (!schema || !tname) { if (!schema || !tname) {
return res.status(400).json({ error: 'schema and tname are required' }); return res.status(400).json({ error: 'schema and tname are required' });
} }

View File

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { fcTable, mapType } = require('../lib/utils'); const { fcTable, mapType } = require('../lib/utils');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) { module.exports = function(pool) {
const router = express.Router(); const router = express.Router();
@ -22,7 +23,8 @@ module.exports = function(pool) {
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction // inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
router.post('/sources/:id/versions', async (req, res) => { router.post('/sources/:id/versions', async (req, res) => {
const sourceId = parseInt(req.params.id); const sourceId = parseInt(req.params.id);
const { name, description, created_by, exclude_iters } = req.body; const { name, description, exclude_iters } = req.body;
const created_by = sessionUser(req);
if (!name) return res.status(400).json({ error: 'name is required' }); if (!name) return res.status(400).json({ error: 'name is required' });
const client = await pool.connect(); const client = await pool.connect();
@ -262,7 +264,7 @@ ${colDefs},
// close a version — blocks further edits // close a version — blocks further edits
router.post('/versions/:id/close', async (req, res) => { router.post('/versions/:id/close', async (req, res) => {
const { pf_user } = req.body; const pf_user = sessionUser(req);
try { try {
const result = await pool.query(` const result = await pool.query(`
UPDATE pf.version UPDATE pf.version

View File

@ -1,7 +1,10 @@
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const session = require('express-session');
const PgSession = require('connect-pg-simple')(session);
const { Pool, types } = require('pg'); const { Pool, types } = require('pg');
const { requireAuth } = require('./lib/auth');
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings, // Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>. // so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
@ -9,7 +12,15 @@ types.setTypeParser(20, v => v === null ? null : Number(v));
types.setTypeParser(1700, v => v === null ? null : Number(v)); types.setTypeParser(1700, v => v === null ? null : Number(v));
const app = express(); const app = express();
app.use(cors());
// Sessions ride on a cookie, so a wildcard CORS origin would let any site make
// credentialed calls on behalf of a logged-in user. The UI is served from this
// same origin and needs no CORS at all; set CORS_ORIGIN only for a separate
// front-end host, and it is then allowed by name, never by wildcard.
if (process.env.CORS_ORIGIN) {
app.use(cors({ origin: process.env.CORS_ORIGIN.split(',').map(o => o.trim()), credentials: true }));
}
app.use(express.json()); app.use(express.json());
app.use(express.static('public/app')); app.use(express.static('public/app'));
@ -26,6 +37,44 @@ pool.on('error', (err) => {
console.error('pg pool error', err); console.error('pg pool error', err);
}); });
// ── Authentication ────────────────────────────────────────────
// Refuse to boot without a secret rather than fall back to a default one:
// a predictable secret means forgeable session cookies.
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
console.error('SESSION_SECRET is not set. Run: ./pf.sh config');
process.exit(1);
}
// TLS terminates at the reverse proxy, so express has to trust its headers for
// req.ip (the login throttle) and for secure-cookie detection to be right.
app.set('trust proxy', process.env.TRUST_PROXY || 1);
const cookieSecure = process.env.COOKIE_SECURE !== 'false';
if (!cookieSecure) {
console.warn('COOKIE_SECURE=false — session cookie will be sent over plain HTTP.');
}
app.use(session({
name: 'pf.sid',
store: new PgSession({ pool, schemaName: 'pf', tableName: 'session', createTableIfMissing: false }),
secret: sessionSecret,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure,
maxAge: 1000 * 60 * 60 * 12,
},
}));
app.use('/api', require('./routes/auth')(pool));
// Everything below this line requires a session.
app.use('/api', requireAuth);
app.use('/api', require('./routes/tables')(pool)); app.use('/api', require('./routes/tables')(pool));
app.use('/api', require('./routes/sources')(pool)); app.use('/api', require('./routes/sources')(pool));
app.use('/api', require('./routes/versions')(pool)); app.use('/api', require('./routes/versions')(pool));

26
setup_sql/02_auth.sql Normal file
View File

@ -0,0 +1,26 @@
-- Pivot Forecast — authentication
-- Run after 01_schema.sql: psql -d <db> -f setup_sql/02_auth.sql
-- Safe to re-run.
-- Application accounts. Passwords are scrypt hashes written by lib/auth.js;
-- the plaintext never reaches the database. Manage with ./pf.sh add-user.
CREATE TABLE IF NOT EXISTS pf.app_user (
id serial PRIMARY KEY,
username text NOT NULL UNIQUE,
pass_hash text NOT NULL,
display_name text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
last_login_at timestamptz
);
-- Session store for express-session (connect-pg-simple layout). Sessions live
-- here rather than in memory so a restart doesn't sign everyone out, and so a
-- session can be revoked by deleting its row.
CREATE TABLE IF NOT EXISTS pf.session (
sid varchar PRIMARY KEY NOT NULL COLLATE "default",
sess json NOT NULL,
expire timestamp(6) NOT NULL
);
CREATE INDEX IF NOT EXISTS session_expire_idx ON pf.session (expire);

48
ui/package-lock.json generated
View File

@ -8,10 +8,10 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@perspective-dev/client": "5.2.0", "@perspective-dev/client": "file:./vendor/perspective-dev-client-5.4.0.tgz",
"@perspective-dev/server": "5.2.0", "@perspective-dev/server": "file:./vendor/perspective-dev-server-5.4.0.tgz",
"@perspective-dev/viewer": "5.2.0", "@perspective-dev/viewer": "file:./vendor/perspective-dev-viewer-5.4.0.tgz",
"@perspective-dev/viewer-datagrid": "5.2.0", "@perspective-dev/viewer-datagrid": "file:./vendor/perspective-dev-viewer-datagrid-5.4.0.tgz",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },
@ -554,43 +554,43 @@
} }
}, },
"node_modules/@perspective-dev/client": { "node_modules/@perspective-dev/client": {
"version": "5.2.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@perspective-dev/client/-/client-5.2.0.tgz", "resolved": "file:vendor/perspective-dev-client-5.4.0.tgz",
"integrity": "sha512-zkJmJFwdw0wMREoJt8gUJyCsflzi7s7Yc8ZtUCOLE9XB6fdyxJmDB99cxO3Q+hno/57kLsz8VNoz8XSm6PZNrg==", "integrity": "sha512-l9xCJ0W42wm9fLlwhoU838KyTE131qQTS686uQb/VcsLs30kCVjkoermCCUs0YHCDwDTJhly1y9DfAZDbkg9Ng==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@perspective-dev/server": "", "@perspective-dev/server": "^5.4.0",
"pro_self_extracting_wasm": "0.0.9", "pro_self_extracting_wasm": "0.0.9",
"stoppable": "=1.1.0", "stoppable": "=1.1.0",
"ws": "^8.17.0" "ws": "^8.17.0"
} }
}, },
"node_modules/@perspective-dev/server": { "node_modules/@perspective-dev/server": {
"version": "5.2.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@perspective-dev/server/-/server-5.2.0.tgz", "resolved": "file:vendor/perspective-dev-server-5.4.0.tgz",
"integrity": "sha512-WRBiokT2/BYM8Ipe7Dg1/2UYNb01Vsox79KBfFVI800VmwdId0GdqI95zufN5ZoFffeBfri1sr3S3AShBRFXKA==", "integrity": "sha512-iTseRJB6TL6D9xjaMKMhh2NEKMIi9JR881J+GyQflHIQXK43fDlsIWtByUAoyZzZ7uA9KNZJZicirvONxIpLuw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@perspective-dev/viewer": { "node_modules/@perspective-dev/viewer": {
"version": "5.2.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@perspective-dev/viewer/-/viewer-5.2.0.tgz", "resolved": "file:vendor/perspective-dev-viewer-5.4.0.tgz",
"integrity": "sha512-IIyqdPduofzzZ/QWrteq29IadJQR8IapTRz7U6XbrtBBm7eyiFsIBKQV0wFfcqWg4TRnhuvCAUvBRieuE0R8Eg==", "integrity": "sha512-7D6jNn7tDZ3W84MsydplWAJbqqpXIz5OlsHx5YG4sHvJ5q8ngZQvP+oZdxLQXL4hIbaxpskMld1gJbGltpcvUw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@perspective-dev/client": "", "@perspective-dev/client": "^5.4.0",
"pro_self_extracting_wasm": "0.0.9", "pro_self_extracting_wasm": "0.0.9",
"regular-layout": "=0.6.1" "regular-layout": "=0.6.1"
} }
}, },
"node_modules/@perspective-dev/viewer-datagrid": { "node_modules/@perspective-dev/viewer-datagrid": {
"version": "5.2.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@perspective-dev/viewer-datagrid/-/viewer-datagrid-5.2.0.tgz", "resolved": "file:vendor/perspective-dev-viewer-datagrid-5.4.0.tgz",
"integrity": "sha512-v/SR/35YfyKfivO21nglJFiyG1iE5G8vMwIwWI6fQuwjW764pmNrm/zcrNWNY70x5XHkPRM94aY6LjkLsLkO2g==", "integrity": "sha512-7ITOmrIh1ZpiQbUR/KmHYck1sLA4cpNgpVMI/qJjQc9k/uypkT1vJmKYxv4MKR24TmEkOouBLNYiO+ItFKs8vg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@perspective-dev/client": "", "@perspective-dev/client": "^5.4.0",
"@perspective-dev/viewer": "", "@perspective-dev/viewer": "^5.4.0",
"regular-table": "=0.8.6" "regular-table": "=0.9.1"
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
@ -2948,9 +2948,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/regular-table": { "node_modules/regular-table": {
"version": "0.8.6", "version": "0.9.1",
"resolved": "https://registry.npmjs.org/regular-table/-/regular-table-0.8.6.tgz", "resolved": "https://registry.npmjs.org/regular-table/-/regular-table-0.9.1.tgz",
"integrity": "sha512-jzJzu9WLtqwMgbf5ak/VdNm/YLHUDnDSCHD5SOksnHrHLuDN6l5i2stYfe7BjsHbQV1Gx9369Ma40nTBCgOFvA==", "integrity": "sha512-3I/2I3NEhmyScCevW/r1AE9hyUkqrPRMDsgo/nDnGXhpXVZOxUddJQGBmEMLgyc7OSaQSzl9BtXllnMqw1MwCA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=16" "node": ">=16"

View File

@ -10,10 +10,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@perspective-dev/client": "5.2.0", "@perspective-dev/client": "file:./vendor/perspective-dev-client-5.4.0.tgz",
"@perspective-dev/server": "5.2.0", "@perspective-dev/server": "file:./vendor/perspective-dev-server-5.4.0.tgz",
"@perspective-dev/viewer": "5.2.0", "@perspective-dev/viewer": "file:./vendor/perspective-dev-viewer-5.4.0.tgz",
"@perspective-dev/viewer-datagrid": "5.2.0", "@perspective-dev/viewer-datagrid": "file:./vendor/perspective-dev-viewer-datagrid-5.4.0.tgz",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },

62
ui/src/auth.jsx Normal file
View File

@ -0,0 +1,62 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
const AuthContext = createContext()
// A session can expire while the app is open. Rather than teach every one of
// the app's fetch calls to check for it, wrap fetch once: any 401 from /api
// drops the whole UI back to the login screen. Cookies ride along on their own
// fetch defaults to same-origin credentials, and the UI is served from the
// same origin as the API.
function installUnauthorizedHandler(onUnauthorized) {
const original = window.fetch
window.fetch = async (...args) => {
const res = await original(...args)
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''
if (res.status === 401 && url.includes('/api/') && !url.includes('/api/login')) {
onUnauthorized()
}
return res
}
return () => { window.fetch = original }
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [checking, setCheck] = useState(true)
useEffect(() => {
fetch('/api/me')
.then(r => r.ok ? r.json() : null)
.then(d => setUser(d?.user || null))
.catch(() => setUser(null))
.finally(() => setCheck(false))
}, [])
useEffect(() => installUnauthorizedHandler(() => setUser(null)), [])
const login = useCallback(async (username, password) => {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(data.error || 'Login failed')
setUser(data.user)
return data.user
}, [])
const logout = useCallback(async () => {
try { await fetch('/api/logout', { method: 'POST' }) } catch {}
setUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, checking, login, logout }}>
{children}
</AuthContext.Provider>
)
}
const useAuth = () => useContext(AuthContext)
export default useAuth

View File

@ -1,8 +1,10 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
import useAuth from '../auth.jsx'
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) { export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
const { dark, setDark } = useTheme() const { dark, setDark } = useTheme()
const { user, logout } = useAuth()
const showVersion = view === 'baseline' || view === 'forecast' const showVersion = view === 'baseline' || view === 'forecast'
const selectedVersion = versions.find(v => String(v.id) === String(versionId)) const selectedVersion = versions.find(v => String(v.id) === String(versionId))
@ -125,7 +127,21 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
</> </>
)} )}
<div className="ml-auto"> <div className="ml-auto flex items-center gap-2">
{user && (
<>
<span className="text-gray-500" title={`Signed in as ${user.username}`}>
{user.display_name || user.username}
</span>
<button
onClick={logout}
className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-0.5 rounded"
title="Sign out"
>
Sign out
</button>
</>
)}
<button <button
onClick={() => setDark(d => !d)} onClick={() => setDark(d => !d)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100" className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"

View File

@ -1,13 +1,27 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { ThemeProvider } from './theme.jsx' import { ThemeProvider } from './theme.jsx'
import { AuthProvider } from './auth.jsx'
import useAuth from './auth.jsx'
import Login from './views/Login.jsx'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
// App is mounted only once there is a session its load effects call /api
// straight away, and mounting it logged-out would just fire a burst of 401s.
function Gate() {
const { user, checking } = useAuth()
if (checking) return <div className="flex items-center justify-center h-screen text-sm text-gray-400">Loading</div>
if (!user) return <Login />
return <App />
}
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<App /> <AuthProvider>
<Gate />
</AuthProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
) )

View File

@ -148,7 +148,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const endpoint = isRef ? 'reference' : 'baseline' const endpoint = isRef ? 'reference' : 'baseline'
const body = { const body = {
where_clause: clause, where_clause: clause,
pf_user: 'admin',
note: description || segNote, note: description || segNote,
date_offset: offsetStr, date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }), ...(useRaw ? { raw_where: clause } : { filters }),
@ -242,7 +241,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
async function closeVersion() { async function closeVersion() {
const res = await fetch(`/api/versions/${versionId}/close`, { const res = await fetch(`/api/versions/${versionId}/close`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pf_user: 'admin' }) body: JSON.stringify({})
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return } if (!res.ok) { flash(data.error, 'error'); return }

View File

@ -23,6 +23,8 @@ function cleanLayout(cfg, validCols) {
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col)) if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
if (c.group_by) c.group_by = c.group_by.filter(ok) if (c.group_by) c.group_by = c.group_by.filter(ok)
if (c.split_by) c.split_by = c.split_by.filter(ok) if (c.split_by) c.split_by = c.split_by.filter(ok)
// the uncollapsed column hierarchy travels with the layout (see applySplitDepth)
if (c.split_full) c.split_full = c.split_full.filter(ok)
if (c.sort) c.sort = c.sort.filter(([col]) => ok(col)) if (c.sort) c.sort = c.sort.filter(([col]) => ok(col))
if (c.filter) c.filter = c.filter.filter(([col]) => ok(col)) if (c.filter) c.filter = c.filter.filter(([col]) => ok(col))
return c return c
@ -42,6 +44,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const [saveAsName, setSaveAsName] = useState('') const [saveAsName, setSaveAsName] = useState('')
// operation panel a selection is a LIST of slices; one entry is the common case // operation panel a selection is a LIST of slices; one entry is the common case
// The column hierarchy and how many of its levels are showing. Mirrored into
// splitFullRef for handlers registered once; held as state so the toolbar
// re-renders when either changes.
const [splitFull, setSplitFull] = useState([])
const [splitDepth, setSplitDepth] = useState(null)
const [slices, setSlices] = useState([]) const [slices, setSlices] = useState([])
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each' const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
const [activeOp, setActiveOp] = useState('scale') const [activeOp, setActiveOp] = useState('scale')
@ -167,6 +175,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const tableRef = useRef(null) const tableRef = useRef(null)
const colMetaRef = useRef([]) const colMetaRef = useRef([])
const expandDepthRef = useRef(null) const expandDepthRef = useRef(null)
// The column axis has no set_depth() collapsing it means restoring a shorter
// split_by, so the full hierarchy has to be remembered separately to expand again.
const splitFullRef = useRef([])
// set while our own restore is in flight, so the config-update listener can tell
// a collapse from the user rearranging split_by themselves
const collapsingRef = useRef(false)
const initIdRef = useRef(0) const initIdRef = useRef(0)
const modifierRef = useRef(false) const modifierRef = useRef(false)
// the datagrid plugin element, for reading cell coordinates and driving its // the datagrid plugin element, for reading cell coordinates and driving its
@ -536,6 +550,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
setLoadProgress(null) setLoadProgress(null)
setSlices([]) setSlices([])
expandDepthRef.current = null expandDepthRef.current = null
adoptSplit([], 0)
try { try {
const [dataResult, meta] = await Promise.all([ const [dataResult, meta] = await Promise.all([
fetch(`/api/versions/${vid}/data`).then(async r => { fetch(`/api/versions/${vid}/data`).then(async r => {
@ -616,6 +631,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols) const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
await viewer.restore(cfg) await viewer.restore(cfg)
// split_full outlives split_by: a layout saved while collapsed still knows
// the levels it was collapsed from
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else { } else {
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
@ -633,12 +651,19 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} }
} }
await viewer.restore(cfg) await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
} }
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache // auto-persist viewer state (formatting, columns, etc.) to the last-used cache
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate) if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
viewer._pspUpdate = async () => { viewer._pspUpdate = async () => {
try { try {
// A split_by change that is not ours is the user rearranging the pivot, and
// it redefines the hierarchy. Ours is a collapse, and must not overwrite it.
if (!collapsingRef.current) {
const live = await viewer.save()
adoptSplit(live.split_by || [], (live.split_by || []).length)
}
const cfg = await captureConfig() const cfg = await captureConfig()
if (cfg) await persistLayout(vid, cfg) if (cfg) await persistLayout(vid, cfg)
} catch {} } catch {}
@ -690,6 +715,54 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} }
} }
// Record the column hierarchy as the uncollapsed truth. Called whenever a layout
// arrives or the user rearranges split_by themselves but never for our own
// collapse, which would otherwise overwrite the full list with the short one.
function adoptSplit(full, depth) {
const list = Array.isArray(full) ? full : []
splitFullRef.current = list
setSplitFull(list)
setSplitDepth(depth == null ? list.length : Math.min(depth, list.length))
}
// Collapse or expand the column axis to `n` split_by levels.
//
// The row axis gets this for free: its GROUP BY ROLLUP view holds every level at
// once and view.set_depth() hides the deeper ones. The column axis has no
// equivalent there is no split_by_depth in ViewConfig and expand()/collapse()
// take a row index so collapsing means restoring a truncated split_by, which
// rebuilds the view. Two consequences fall out of that: the row depth has to be
// re-applied afterwards (it lives on the discarded view), and it is whole-axis,
// not per-branch every column group collapses to the same level together.
async function applySplitDepth(n) {
const viewer = viewerRef.current
const full = splitFullRef.current
if (!viewer || !full.length) return
const depth = Math.max(0, Math.min(n, full.length))
collapsingRef.current = true
try {
await viewer.restore({ split_by: full.slice(0, depth) })
setSplitDepth(depth)
// restore() rebuilt the view, so the row depth that lived on the old one is gone
if (expandDepthRef.current != null) await applyDepth(expandDepthRef.current)
} catch (err) {
console.error('[applySplitDepth]', err)
flash(err.message || String(err), 'error')
return
} finally {
collapsingRef.current = false
}
// a slice names the split_by dimensions it was cut from, and the highlight is
// keyed on grid coordinates neither survives a column axis that just changed
setSlices([])
try {
const cfg = await captureConfig()
if (cfg) await persistLayout(versionId, cfg)
} catch (err) {
console.error('[applySplitDepth persist]', err)
}
}
async function applyDepth(d) { async function applyDepth(d) {
const viewer = viewerRef.current const viewer = viewerRef.current
if (!viewer) return if (!viewer) return
@ -704,7 +777,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const viewer = viewerRef.current const viewer = viewerRef.current
if (!viewer) return null if (!viewer) return null
const cfg = await viewer.save() const cfg = await viewer.save()
return { ...cfg, expand_depth: expandDepthRef.current } return { ...cfg, expand_depth: expandDepthRef.current, split_full: splitFullRef.current }
} }
async function persistLayout(vid, cfg) { async function persistLayout(vid, cfg) {
@ -762,6 +835,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const cfg = cleanLayout(layout.config, validCols) const cfg = cleanLayout(layout.config, validCols)
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
await viewer.restore(cfg) await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
setActiveLayoutId(layout.id) setActiveLayoutId(layout.id)
await persistLayout(versionId, cfg) await persistLayout(versionId, cfg)
@ -871,7 +945,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} }
// apply_mode only changes the maths when more than one slice is selected // apply_mode only changes the maths when more than one slice is selected
let body = { let body = {
pf_user: 'admin',
tag: opTag.trim() || undefined, tag: opTag.trim() || undefined,
slices: effectiveSlices, slices: effectiveSlices,
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}), ...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
@ -1087,6 +1160,30 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
))} ))}
</div> </div>
{splitFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Column hierarchy group — the split_by equivalent of Expand */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Columns</span>
{Array.from({ length: splitFull.length + 1 }, (_, n) => {
const label = n === 0 ? 'Total' : splitFull[n - 1]
return (
<button key={n} onClick={() => applySplitDepth(n)}
title={n === 0
? 'Collapse the columns to a single total'
: `Show columns down to ${splitFull.slice(0, n).join(' ')}`}
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
${splitDepth === n ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{label}
</button>
)
})}
</div>
</>
)}
<div className="w-px h-4 bg-gray-200 shrink-0" /> <div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Data group */} {/* Data group */}

68
ui/src/views/Login.jsx Normal file
View File

@ -0,0 +1,68 @@
import { useState } from 'react'
import useAuth from '../auth.jsx'
export default function Login() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
async function submit(e) {
e.preventDefault()
setError(''); setBusy(true)
try {
await login(username.trim(), password)
} catch (err) {
setError(err.message)
setPassword('')
} finally {
setBusy(false)
}
}
return (
<div className="flex items-center justify-center h-screen w-full text-sm">
<form onSubmit={submit} className="bg-white border border-gray-200 rounded p-6 w-80 flex flex-col gap-4">
<div>
<div className="text-base font-medium">Pivot Forecast</div>
<div className="text-xs text-gray-500 mt-0.5">Sign in to continue</div>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500">Username</span>
<input
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
autoComplete="username"
className="border border-gray-200 rounded px-2 py-1.5 text-sm"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500">Password</span>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
className="border border-gray-200 rounded px-2 py-1.5 text-sm"
/>
</label>
{error && (
<div className="px-3 py-2 text-xs rounded font-medium bg-red-50 text-red-700">{error}</div>
)}
<button
type="submit"
disabled={busy || !username.trim() || !password}
className="bg-blue-600 text-white text-xs px-3 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}

7
ui/vendor/PROVENANCE.txt vendored Normal file
View File

@ -0,0 +1,7 @@
Built from https://github.com/fleetside72/perspective
branch column-axis-expand-collapse
commit 2e3901d652650a33eaf19c2ddf049f7e525ea95b
based on v5.4.0
built 2026-09-14T02:40:11Z on r710.hptrow.me
Regenerate with ui/vendor/rebuild-perspective.sh

50
ui/vendor/README.md vendored Normal file
View File

@ -0,0 +1,50 @@
# Vendored Perspective
pf_app runs a **patched build of Perspective**. Upstream's C++ engine has always
implemented column-axis expand/collapse — `t_ctx2::set_depth(HEADER_COLUMN, …)`
and `open`/`close(HEADER_COLUMN, idx)` are fully written — but nothing above C++
could reach it: `set_column_pivot_depth()` was never called, and
`View<t_ctx2>::expand/collapse` hardcoded `HEADER_ROW`. The patch is wiring, not
new engine logic.
It buys two things the released packages cannot do at all:
- `split_by_depth` in `ViewConfig`, the `split_by` counterpart to `group_by_depth`
- `expand_column()` / `collapse_column()`, so one column branch can fold to its
subtotal while its siblings stay expanded — the Excel behaviour
**Source:** https://github.com/fleetside72/perspective, branch
`column-axis-expand-collapse`. See `PROVENANCE.txt` for the exact commit these
tarballs were built from.
## Why tarballs and not npm
The feature is not released upstream. Until it is, the four packages are built
from the fork and committed here as npm tarballs. `npm install` expands them
exactly as it expands anything from the registry — no special tooling, and
`pf.sh deploy` works unchanged. A deploy machine needs node and nothing else:
no emscripten, no cmake, no protoc, no Rust.
All four move together, never a subset. Perspective couples loader, package
versions, data format and `apache-arrow`; vendoring a partial set reintroduces
exactly the drift that causes trouble.
## Changing the engine
./rebuild-perspective.sh # builds the fork, repacks, rewrites PROVENANCE.txt
cd .. && npm install
git add vendor && git commit
Push the fork first — the script warns if the source tree is dirty, because a
tarball built from uncommitted code has no recoverable source.
The build itself needs cmake >= 3.29.5, protoc >= 22 (its version silently
selects which protobuf source tree gets cloned), pnpm, and the Rust nightly the
repo pins. Roughly 40 minutes cold. Only ever on a machine changing the engine.
## Getting rid of this
This is a fork, with the maintenance that implies. The exit is upstream taking
the change — the patch is small and additive, and the engine work is already
theirs. When a release ships it, delete this directory and put normal version
ranges back in `ui/package.json`.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

102
ui/vendor/rebuild-perspective.sh vendored Executable file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# rebuild-perspective.sh — rebuild the patched Perspective and re-vendor it
#
# pf_app runs a patched build of Perspective that exposes the column axis
# expand/collapse the engine already implements (split_by_depth, and
# expand_column/collapse_column). Upstream does not ship this yet, so the
# built packages are vendored into this directory as npm tarballs.
#
# Source of truth: https://github.com/fleetside72/perspective
# branch column-axis-expand-collapse
#
# This script exists because vendored binaries are opaque: once the .tgz files
# are committed, nothing in the repo records how to regenerate them. Run this
# after changing the fork, then commit the resulting tarballs.
#
# Only needed on a machine that is changing the engine. Deploys just run
# `npm install`, which expands the committed tarballs - see ../README in this
# directory.
# ---------------------------------------------------------------------------
PSP="${PSP_DIR:-$HOME/perspective}"
BRANCH="${PSP_BRANCH:-column-axis-expand-collapse}"
VENDOR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The four packages that must move together. Perspective's own docs are
# emphatic that loader, packages, data format and apache-arrow are one unit;
# vendoring a subset would reintroduce exactly the drift that causes trouble.
PACKAGES=(
"rust/perspective-js"
"rust/perspective-server"
"rust/perspective-viewer"
"packages/viewer-datagrid"
)
info() { echo -e "\033[0;34m==>\033[0m $*"; }
ok() { echo -e "\033[0;32m ✓\033[0m $*"; }
die() { echo -e "\033[0;31m ✗\033[0m $*" >&2; exit 1; }
# -- preflight --------------------------------------------------------------
[[ -d "$PSP" ]] || die "No Perspective checkout at $PSP.
git clone https://github.com/fleetside72/perspective.git $PSP
cd $PSP && git checkout $BRANCH
Set PSP_DIR to use a different path."
command -v pnpm >/dev/null || die "pnpm not found. Perspective builds with pnpm, not npm."
command -v protoc >/dev/null || die "protoc not found.
Its VERSION selects which protobuf source tree the build clones, and a
version below 22 pulls a layout the build cannot consume. Needs >= 22
(33.2 known good). Distro packages are usually far too old."
cmake_ver=$(cmake --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?') || die "cmake not found"
cmake_major=${cmake_ver%%.*}; cmake_minor=$(echo "$cmake_ver" | cut -d. -f2)
if (( cmake_major < 3 || (cmake_major == 3 && cmake_minor < 29) )); then
die "cmake $cmake_ver is too old; Perspective needs >= 3.29.5.
A user-level install works: pip3 install --user 'cmake>=3.29.5'"
fi
info "Perspective checkout: $PSP"
git -C "$PSP" rev-parse --abbrev-ref HEAD | grep -qx "$BRANCH" \
|| echo " ! on branch $(git -C "$PSP" rev-parse --abbrev-ref HEAD), expected $BRANCH"
commit=$(git -C "$PSP" rev-parse --short HEAD)
dirty=$(git -C "$PSP" status --porcelain | wc -l)
echo " commit $commit$([[ $dirty -gt 0 ]] && echo " (+$dirty uncommitted files)")"
# -- build ------------------------------------------------------------------
# `metadata` first: it generates docs/expression_gen.md, which perspective-client
# includes at compile time. Building a scope without it fails on the missing file.
info "Building (this takes ~40 minutes cold, a few minutes warm)…"
( cd "$PSP" && PSP_ONCE=1 PACKAGE="metadata,server,client,viewer,viewer-datagrid" pnpm run build )
ok "build complete"
# -- pack -------------------------------------------------------------------
info "Packing tarballs into $VENDOR"
rm -f "$VENDOR"/*.tgz
for p in "${PACKAGES[@]}"; do
( cd "$PSP/$p" && npm pack --pack-destination "$VENDOR" >/dev/null )
ok "$(basename "$p")"
done
# -- record provenance ------------------------------------------------------
# A committed .tgz is an opaque binary; without this the tie back to source is
# only in someone's memory.
cat > "$VENDOR/PROVENANCE.txt" <<EOF
Built from https://github.com/fleetside72/perspective
branch $BRANCH
commit $(git -C "$PSP" rev-parse HEAD)
based on $(git -C "$PSP" describe --tags --abbrev=0 2>/dev/null || echo 'unknown')
built $(date -u +%Y-%m-%dT%H:%M:%SZ) on $(hostname)
dirty $dirty uncommitted file(s) in the source tree at build time
Regenerate with ui/vendor/rebuild-perspective.sh
EOF
echo
ls -la "$VENDOR"/*.tgz | awk '{printf " %-52s %5.1f MB\n", $NF, $5/1048576}'
echo
ok "Done. Now: cd ui && npm install && git add vendor && git commit"
[[ $dirty -gt 0 ]] && echo -e "\033[1;33m !\033[0m source tree had uncommitted changes — push them to the fork first"
exit 0