From 4d85187fb5c8a7d55ed9e61a047700a8bf488c5d Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Mon, 10 Aug 2026 14:32:58 -0300 Subject: [PATCH] README for first build --- Makefile | 9 ++- README.md | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ VERSION | 2 +- package.json | 4 +- src/config.ts | 2 +- src/content.ts | 8 +-- src/format.ts | 2 +- src/injected.ts | 4 +- src/options.ts | 4 +- src/popup.ts | 2 +- tsconfig.json | 7 +- 11 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 README.md diff --git a/Makefile b/Makefile index 78a2fec..4338a0a 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,7 @@ # # Usage: # make # typecheck and bundle into build/ +# make test # run the unit tests # make dist # build, then zip build/ into logsdu-.zip # make clean # remove build/ and the zip # @@ -36,10 +37,16 @@ EXT_ID := logsdu VERSION := $(shell node -p "require('./package.json').version") DIST := $(EXT_ID)-$(VERSION).zip -.PHONY: all build dist clean check-deps +.PHONY: all build test dist clean check-deps all: build +# Unit tests for the pure logic: the input formatters and the decision that +# says whether a page load may press "Entrar". Run straight through Node's +# built-in runner and type stripping, so there is no test framework to install. +test: + node --test "src/**/*.test.ts" + # Call the local toolchain directly, so this works regardless of how pnpm is # provided (corepack vs standalone). Run "corepack pnpm install" first. build: check-deps diff --git a/README.md b/README.md new file mode 100644 index 0000000..80bac64 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# logsdu + +A Firefox extension that logs you into an academic portal whose sign-in form is +three fields -- registration number, birth date and national ID -- rather than +the usual user and password. + +That shape defeats password managers. The form is marked `autocomplete="off"`, +none of the three inputs is a `password` field, and Bitwarden, Firefox and +Chrome all decline to remember it. Since the values never change and the +institution does not let you pick a different login method, the only option +left is copying three values by hand, every time. + +logsdu stores them once and fills them in. By default it also presses "Entrar", +so the normal case is zero clicks. + +## Install + +Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below). +Node 22+ ships `corepack`, so pnpm does not have to be installed globally. + +``` +corepack pnpm install # first time, or after a dependency change +make # typecheck and bundle into build/ +``` + +Then load it into Firefox: + +1. Open `about:debugging` +2. Click **This Firefox** +3. Click **Load Temporary Add-on** +4. Select `build/manifest.json` + +Open the extension's options page, fill in the four values, and save. + +A temporary add-on is dropped when Firefox restarts. See "Installing it +permanently" for the fix. + +## The four values + +| Field | Example | Notes | +| --- | --- | --- | +| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored | +| Registration number | `2000101010` | Digits only | +| Birth date | `01/01/2000` | Reformatted as you type | +| National ID | `000.000.000-00` | Reformatted as you type | + +Paste raw digits if you like -- the options page inserts the separators, because +the portal's input masks expect the values in exactly that shape. + +**The portal address is configuration, not code.** No institution is named +anywhere in the extension: not in the manifest, not in the source, not in the +build output. Be clear about what that does and does not buy you. It stops +someone who reads the extension from learning which portal it is for. It does +not hide anything from someone who can read the extension's storage -- and that +is the same access that would expose your national ID and birth date anyway. + +## How it behaves + +- **Fills and submits** on the login page, with no interaction. +- **At most one automatic submit per hour.** After an attempt, the extension + drops back to filling only, so a wrong value cannot resubmit itself on every + page load and lock you out. Correct the values and save; saving clears the + timer, so the next visit tries again immediately. +- **Logging out keeps you logged out.** Clicking the portal's logout control + suppresses the automatic submit for five minutes -- otherwise the logout + redirect lands on the login page and you would be signed straight back in. +- When it fills without submitting, a small note at the bottom of the page says + why. Click it to dismiss. +- The toolbar popup has a **Preencher agora** button that fills without + submitting, for when you want to check the values before sending them. +- Automatic submission can be turned off entirely in the options. + +## How the values are stored + +In `storage.local`: private to this browser profile, never synced, never sent +anywhere. That is the same protection a browser-saved password gets, and it has +the same limit -- anyone with your unlocked account can read it. If that is not +good enough for your threat model, this extension is the wrong tool. + +## Installing it permanently + +Firefox only keeps unsigned add-ons until the next restart. Two ways around it: + +- **Sign it (recommended).** `make dist` produces `logsdu-.zip`. Upload + it to [addons.mozilla.org](https://addons.mozilla.org) as an **unlisted** + add-on: signing is automated, nothing is published publicly or reviewed by + hand, and you install the signed `.xpi` it hands back. +- **Turn signing off.** Firefox Developer Edition, Nightly or ESR will install + unsigned add-ons with `xpinstall.signatures.required=false` in `about:config`. + Release Firefox ignores that setting. + +## Chrome + +Not yet. The source deliberately avoids anything Firefox-specific: it uses the +`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build +should be a manifest question rather than a rewrite. It has not been tried, so +do not assume it works. + +## Development + +``` +corepack pnpm install +make test # unit tests +make # typecheck and bundle +corepack pnpm run dev # rebuild on change +make clean +``` + +After a rebuild, press **Reload** next to the extension in `about:debugging`. + +`corepack pnpm run dev` watches and rebuilds, including the static files, but +Firefox still needs that Reload click to pick anything up. + +### Layout + +| Path | Role | +| --- | --- | +| `src/manifest.json` | MV3 manifest. Names no site | +| `src/content.ts` | Isolated world. Decides whether to act, then delegates | +| `src/injected.ts` | Page world. Does the actual filling and clicking | +| `src/portal.ts` | Every selector the extension knows about the form | +| `src/config.ts` | Stored values, the rate limit and the logout cooldown | +| `src/format.ts` | Input normalisers for the three masked fields | +| `src/options.*`, `src/popup.*` | The two bits of UI | + +### Why two scripts instead of one + +The portal drives its inputs with Inputmask, which replaces each element's +`value` property with its own accessor. A content script assigning +`input.value` from the isolated world writes through Xrays to the *native* +setter and skips that accessor: the field looks right on screen, but the mask's +buffer is unchanged, and the page's submit handler reads the stale buffer back +out through jQuery. So the filling happens inside the page, through the page's +own jQuery and Inputmask. + +Submitting is a real click on the button, never `form.submit()`. The portal +intercepts the submit event, cancels it, and posts by AJAX with a CSRF token +taken from a meta tag. `form.submit()` would bypass that handler and lose the +token; a click reproduces exactly what a person pressing "Entrar" does. + +### Why the content script matches every URL + +The address is configured at runtime, so it cannot also be a manifest match +pattern -- putting it there is exactly what would name the institution in the +shipped code. The script therefore loads everywhere and stops immediately +unless the page's origin equals the configured one. Origins are compared whole, +so `portal.example.br.evil.tld` does not match. + +The honest cost: the extension holds read access to every page you visit. The +alternative -- registering the content script at runtime with +`scripting.registerContentScripts` and an optional host permission -- avoids +that at the price of a background script and a permission prompt. + +### Why pnpm, not npm + +**Do not run `npm install` or `npm ci` in this repo.** This project is developed +on **ZFS**, and npm's installer renames many directories in parallel while +hoisting and deduping, which ZFS intermittently fails with: + +``` +npm error code ENOTEMPTY +npm error syscall rename +``` + +pnpm hard-links packages from a global content-addressable store and does not +perform that rename dance, so it is unaffected. Only `npm install` / `npm ci` +are the problem -- running *scripts* through npm is fine, since that just spawns +tsc and esbuild. The lockfile is `pnpm-lock.yaml`; there is no +`package-lock.json` and none should be created. + +## Licence + +GPL-3.0-or-later. See `LICENSE`. diff --git a/VERSION b/VERSION index 5f0bac4..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -logsdu v0.1 +0.1.0 diff --git a/package.json b/package.json index ff2c2d5..ad78ebf 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "packageManager": "pnpm@9.15.9", "scripts": { "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "node --test \"src/**/*.test.ts\"", + "test:watch": "node --test --watch \"src/**/*.test.ts\"" }, "license": "GPL-3.0-or-later", "devDependencies": { diff --git a/src/config.ts b/src/config.ts index afd457d..aee12a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,7 +19,7 @@ // * rcb@beco.cc * // ************************************************************************* -import { toOrigin } from './format'; +import { toOrigin } from './format.ts'; const CONFIG_KEY = 'config'; const STATE_KEY = 'state'; diff --git a/src/content.ts b/src/content.ts index 17a4ca3..d19cc0e 100644 --- a/src/content.ts +++ b/src/content.ts @@ -31,10 +31,10 @@ import { loadState, matchesSite, saveState, -} from './config'; -import type { Config } from './config'; -import { EVENT_RESULT, FORM, LOGOUT } from './portal'; -import type { FillRequest, FillResult } from './portal'; +} from './config.ts'; +import type { Config } from './config.ts'; +import { EVENT_RESULT, FORM, LOGOUT } from './portal.ts'; +import type { FillRequest, FillResult } from './portal.ts'; const FORM_WAIT_MS = 10000; const NOTICE_ID = 'logsdu-notice'; diff --git a/src/format.ts b/src/format.ts index e0a36fb..0b28737 100644 --- a/src/format.ts +++ b/src/format.ts @@ -84,7 +84,7 @@ export function isCompleteCpf(value: string): boolean { /** * Origin of a user-typed site address, or null if it cannot be parsed. - * Accepts input without a scheme ("saladigital.example.com") by assuming + * Accepts input without a scheme ("portal.example.br") by assuming * https, which is what someone pasting an address from the URL bar expects. */ export function toOrigin(value: string): string | null { diff --git a/src/injected.ts b/src/injected.ts index 3b5b700..faf3e40 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -37,8 +37,8 @@ // before that handler exists would trigger a plain browser form POST without // the CSRF header, which fails. -import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal'; -import type { FillRequest, FillResult } from './portal'; +import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts'; +import type { FillRequest, FillResult } from './portal.ts'; const POLL_INTERVAL_MS = 100; const POLL_TIMEOUT_MS = 15000; diff --git a/src/options.ts b/src/options.ts index 8ac910e..2d5b1fe 100644 --- a/src/options.ts +++ b/src/options.ts @@ -19,7 +19,7 @@ // * rcb@beco.cc * // ************************************************************************* -import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config'; +import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts'; import { formatCpf, formatDate, @@ -28,7 +28,7 @@ import { isCompleteDate, isCompleteRa, toOrigin, -} from './format'; +} from './format.ts'; function el(id: string): T { const found = document.getElementById(id); diff --git a/src/popup.ts b/src/popup.ts index 5c61131..3d9920b 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -23,7 +23,7 @@ // the automatic submit is switched off, rate limited, or suppressed after a // logout. -import { isConfigured, loadConfig, matchesSite } from './config'; +import { isConfigured, loadConfig, matchesSite } from './config.ts'; const state = document.getElementById('state') as HTMLParagraphElement; const detail = document.getElementById('detail') as HTMLParagraphElement; diff --git a/tsconfig.json b/tsconfig.json index 3f35db2..cbcdadf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,12 +8,15 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, - "moduleResolution": "node", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, "isolatedModules": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "allowSyntheticDefaultImports": true, "lib": ["ES2021", "DOM"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] }