Skip to content

feat(#49): scope history deletes to the caller, add PULLMD_ALLOW_SIGNUP - #50

Merged
syswave-dev merged 12 commits into
mainfrom
feat/delete-scope-signup-switch
Jul 31, 2026
Merged

feat(#49): scope history deletes to the caller, add PULLMD_ALLOW_SIGNUP#50
syswave-dev merged 12 commits into
mainfrom
feat/delete-scope-signup-switch

Conversation

@syswave-dev

Copy link
Copy Markdown
Collaborator

Closes #49.

Two things were asked for: let a logged-in non-admin clear entries from their own history, and make self-registration switchable so a public demo instance can run in multi-user mode without collecting stranger accounts. Diagnosing the first turned up three more defects in the same code paths, fixed here as well.

Delete scope instead of a 403

adminOnly is gone. Both delete routes now pick a scope rather than rejecting:

Caller DELETE /api/cache/:id DELETE /api/cache
Non-admin unlinks their own user_fetches row. Shared conversions row and /s/:id survive, other users unaffected clears only their own history
Admin, or any caller when auth is disabled/unconfigured drops the shared row (unchanged) global purge (unchanged)

Responses gained scope: "user" \| "global", and delete-all gained removed. Existing status codes are unchanged: 400 on an unparsable id, 404 when nothing was deleted, 401 unauthenticated. The predicate isGlobalScope is character-for-character the condition the deleted middleware used, so admin semantics are preserved rather than reimplemented.

Behaviour change worth a CHANGELOG line: in single-admin/multi-user, a non-admin DELETE /api/cache* now answers 200 with scope: "user" instead of 403 {"error":"Admin required"}.

PULLMD_ALLOW_SIGNUP

Default on, so existing multi-user instances are unaffected. Only false/0/no/off closes registration, in which case the /signup routes are not mounted at all (404 for GET and POST, no user creatable), the login page drops its "create an account" link, and /api/config reports signupOpen: false.

createAuth exposes allowSignup (raw) and signupOpen (effective: mode === 'multi-user' && allowSignup). Only the effective value is read anywhere in production code.

This also fixes a live bug: loginPage() rendered the /signup link unconditionally, so in single-admin mode the login page linked to a route that does not exist.

create-user in the admin CLI

node scripts/admin.js create-user <email> with a password prompt, so "registration closed" is not a state an operator cannot escape.

Three defects found along the way

  • Orphaned user_fetches rows. pruneOld runs on every put() and drops conversions older than 90 days, but never removed the matching fetch rows, and countForUser counts without the join that historyPageForUser uses. The archive therefore reported more entries than it could return, and the drift was permanent. Cleanup is now explicit SQL, plus a one-time sweep at createCache. A real FK with ON DELETE CASCADE was deliberately not used: enabling PRAGMA foreign_keys would start enforcing constraints across the oauth and session tables too. Measured cost of the sweep: 4 ms on a synthetic 50k/50k database.
  • readPassword() read nothing from non-TTY stdin. node:readline/promises' question() returns a promise and ignores a callback, but the non-TTY fallback passed one. Any piped invocation printed the prompt and exited 0 having done nothing. This affected the pre-existing reset-password for as long as that command has existed. Guarded now by a child-process test.
  • showError() was invisible while the archive view was open. showArchive() puts an inline display:none on the shared error element, which outranks the .error.visible class rule, and both archive delete buttons live inside that view. Two of the three delete paths would have kept failing silently.

Frontend

All three delete paths surface failures through the existing showError(). The tooltip and the delete-all confirmation state their scope, derived from a predicate mirroring the server's. That flag is deliberately tri-state: 'unknown' (/api/me answered nothing) is treated as a global delete, because claiming "global" for a delete that turns out to be scoped costs nothing while the reverse puts a harmless label on a destructive action.

Verification

  • node --test: 1091 pass / 0 fail (1038 on main).
  • python3 markitdown-sidecar/test_limits.py and test_youtube.py: 4 and 9 passed. These are not covered by node --test.
  • Both new regression tests were proven non-vacuous by reverting the fix they guard and observing exactly that test fail.
  • create-user smoke-tested end to end: creation via pipe, email trimmed and lowercased, duplicate in different case rejected, short password rejected, reset-password working via pipe, both accounts authenticating afterwards.

package.json is untouched; the version bump belongs to the release. Backward compatible for anyone setting none of the new variables, so this is a minor.

Deliberately deferred

db.transaction construction placement in two admin-frequency cache methods, an idempotence test for the orphan sweep, !auth coverage for the delete-all route, and a symmetric override truth table. None affect behaviour; each was triaged as ship-as-is.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KfovDXWcqT7abP6EqFedk5

syswave-dev and others added 12 commits July 31, 2026 17:00
Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfovDXWcqT7abP6EqFedk5
Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfovDXWcqT7abP6EqFedk5
The call rode along in 7876db9 and is not needed: createCache already
creates the users table, and createUserCmd needs nothing from the
migration. Since the CLI defaults to mode 'multi-user' while the
server defaults to 'disabled', running list-users on an auth-disabled
instance with no bootstrap credentials in the environment made this
read-only command crash, or silently bootstrap an admin user and claim
every existing conversion.

Add a regression test that spawns the CLI with no PULLMD_AUTH_MODE,
PULLMD_ADMIN_EMAIL or PULLMD_ADMIN_PASSWORD and asserts the users
table stays empty afterward.
isAdminUser() only read dataset.isAdmin, which the auth bootstrap
never sets in disabled mode (it returns before that assignment). On
every auth-disabled instance - the default, and the operator's own
production box - both delete buttons promised a user-scoped delete
while the server always performs a global one that also invalidates
the /s/:id share link.

Replace it with isGlobalDelete(), mirroring the server's
isGlobalScope predicate (no auth, disabled mode, or admin all delete
globally). Also split the delete-all confirmation into scoped and
global variants so an admin sees the wider blast radius.
- test/cache-users.test.js: cover the pruneOld orphan guard (it only
  runs pruneOrphanFetches when pruneOld actually removed rows), driven
  through put() with an aged entry, per the design doc's requirement
  that this test was missing.
- test/integration-auth.test.js: rename the describe block from
  "admin-only cache deletion" to "cache deletion scope" - it now also
  covers the non-admin success case.
- server.js: rename the local `global` to `isGlobal` in the DELETE
  /api/cache/:id handler; it shadowed Node's built-in `global`.
- lib/auth.js: coerce the `allowSignup` override to boolean instead of
  passing a non-boolean value straight through `??`.
- test/auth-admin-cli.test.js: resolve the repo root with
  fileURLToPath instead of new URL('..', ...).pathname, which breaks
  on paths with percent-encoded characters; assert stderr is empty
  where it was destructured but unused.
- REVIEW-FINDINGS.md, MIGRATION.md: note that S-2's admin-only fix and
  multi-user's unconditional self-signup were both superseded in
  3.8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfovDXWcqT7abP6EqFedk5
@syswave-dev
syswave-dev merged commit d7d26a6 into main Jul 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-admin users cannot delete entries from their own history

1 participant