← back to AbramsOS
feat: 'Import all receipts' button + 2FA gate + Plaid sandbox
2113e8d3d1e78a678b5d1874811bed1d10c73aa9 · 2026-05-10 00:11:43 -0700 · Steve
- Auth: bcrypt password + otplib TOTP (single-user, locked after first signup)
- Step-up TOTP re-verify within 60s before /import or any 'Import all' run
- 4 new tables: auth_credential, auth_totp, auth_session, auth_event
- DB-backed sessions (signed cookie aos.sid, 30d TTL, httpOnly+sameSite=lax)
- Connectors: Gmail (already wired) + Drive (drive.readonly added) + Plaid sandbox
- Plaid: link/token + exchange + transactions/sync; access tokens AES-256-GCM at rest
- /import dashboard fans out to every connected connector via Promise.all + cookie-forward
- Compliance posture updated (PCI-aware: never PAN/CVV, only Plaid item refs)
- 19/19 tests green (5 smoke + 6 extractor + 8 auth-e2e)
Files touched
M .env.exampleA db/migrations/0001_auth.sqlM docs/COMPLIANCE.mdA lib/auth.jsA lib/connectors-catalog.jsM lib/google-oauth.jsA lib/plaid-client.jsA middleware/auth.jsM package-lock.jsonM package.jsonM public/css/app.cssA routes/auth-app.jsA routes/import.jsA routes/plaid.jsM server.jsA tests/auth-e2e.test.jsM tests/smoke.test.jsA views/enroll-totp.ejsA views/import.ejsM views/partials/header.ejsA views/signin.ejsA views/signup.ejsA views/step-up.ejs
Diff
commit 2113e8d3d1e78a678b5d1874811bed1d10c73aa9
Author: Steve <steve@designerwallcoverings.com>
Date: Sun May 10 00:11:43 2026 -0700
feat: 'Import all receipts' button + 2FA gate + Plaid sandbox
- Auth: bcrypt password + otplib TOTP (single-user, locked after first signup)
- Step-up TOTP re-verify within 60s before /import or any 'Import all' run
- 4 new tables: auth_credential, auth_totp, auth_session, auth_event
- DB-backed sessions (signed cookie aos.sid, 30d TTL, httpOnly+sameSite=lax)
- Connectors: Gmail (already wired) + Drive (drive.readonly added) + Plaid sandbox
- Plaid: link/token + exchange + transactions/sync; access tokens AES-256-GCM at rest
- /import dashboard fans out to every connected connector via Promise.all + cookie-forward
- Compliance posture updated (PCI-aware: never PAN/CVV, only Plaid item refs)
- 19/19 tests green (5 smoke + 6 extractor + 8 auth-e2e)
---
.env.example | 6 +
db/migrations/0001_auth.sql | 51 ++++
docs/COMPLIANCE.md | 17 +-
lib/auth.js | 146 ++++++++++++
lib/connectors-catalog.js | 50 ++++
lib/google-oauth.js | 1 +
lib/plaid-client.js | 22 ++
middleware/auth.js | 32 +++
package-lock.json | 557 ++++++++++++++++++++++++++++++++++++++++++++
package.json | 5 +
public/css/app.css | 78 +++++++
routes/auth-app.js | 170 ++++++++++++++
routes/import.js | 79 +++++++
routes/plaid.js | 167 +++++++++++++
server.js | 53 +++--
tests/auth-e2e.test.js | 132 +++++++++++
tests/smoke.test.js | 22 +-
views/enroll-totp.ejs | 25 ++
views/import.ejs | 132 +++++++++++
views/partials/header.ejs | 6 +
views/signin.ejs | 33 +++
views/signup.ejs | 21 ++
views/step-up.ejs | 18 ++
23 files changed, 1786 insertions(+), 37 deletions(-)
diff --git a/.env.example b/.env.example
index 45ad264..fad1ddd 100644
--- a/.env.example
+++ b/.env.example
@@ -28,3 +28,9 @@ OLLAMA_MODEL=qwen3:14b
# Branded contact
INFO_EMAIL=info@abramsos.agentabrams.com
+
+# Plaid — bank/card aggregator. Sandbox creds free at https://dashboard.plaid.com.
+# Route via /secrets so they fan out to .env. Sandbox uses test institutions; user_good / pass_good for the test login.
+PLAID_ENV=sandbox
+PLAID_CLIENT_ID=
+PLAID_SECRET=
diff --git a/db/migrations/0001_auth.sql b/db/migrations/0001_auth.sql
new file mode 100644
index 0000000..39ac333
--- /dev/null
+++ b/db/migrations/0001_auth.sql
@@ -0,0 +1,51 @@
+-- 0001_auth.sql — auth + 2FA tables
+-- Apply: psql -d abrams_os -f db/migrations/0001_auth.sql
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS auth_credential (
+ id text PRIMARY KEY,
+ user_id text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+ password_hash text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ rotated_at timestamptz,
+ UNIQUE (user_id)
+);
+
+CREATE TABLE IF NOT EXISTS auth_totp (
+ id text PRIMARY KEY,
+ user_id text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+ secret_encrypted bytea NOT NULL,
+ secret_iv bytea NOT NULL,
+ secret_tag bytea NOT NULL,
+ enrolled_at timestamptz, -- NULL until first successful TOTP verify
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (user_id)
+);
+
+CREATE TABLE IF NOT EXISTS auth_session (
+ id text PRIMARY KEY, -- random, sent in cookie
+ user_id text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+ step_up_at timestamptz, -- last successful TOTP step-up
+ created_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL,
+ ip inet,
+ user_agent text,
+ revoked_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS auth_session_user_idx ON auth_session (user_id, expires_at DESC);
+
+CREATE TABLE IF NOT EXISTS auth_event (
+ id bigserial PRIMARY KEY,
+ occurred_at timestamptz NOT NULL DEFAULT now(),
+ user_id text REFERENCES user_account(id) ON DELETE SET NULL,
+ session_id text REFERENCES auth_session(id) ON DELETE SET NULL,
+ event_type text NOT NULL, -- signup, signin_pwd_ok, signin_pwd_fail, totp_enroll, totp_ok, totp_fail, step_up_ok, step_up_fail, signout, session_revoked, import_initiated
+ ip inet,
+ user_agent text,
+ metadata_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX IF NOT EXISTS auth_event_user_idx ON auth_event (user_id, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS auth_event_type_idx ON auth_event (event_type, occurred_at DESC);
+
+COMMIT;
diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md
index 28618c7..de7f604 100644
--- a/docs/COMPLIANCE.md
+++ b/docs/COMPLIANCE.md
@@ -2,22 +2,25 @@
AbramsOS is **consumer-only** in v0 and operates as a personal tool for Steve. The compliance bar will rise in lockstep with each milestone in `ROADMAP.md`. Document this file as the system grows.
-## Today (Phase 0)
+## Today (Phase 0 + auth/Plaid v1, 2026-05-10)
| Area | Posture |
|---|---|
-| Authentication | local single-user; no public exposure |
-| OAuth | server-side authorization-code flow against Google for Gmail readonly |
-| Tokens | refresh tokens AES-256-GCM-encrypted at rest with `ENCRYPTION_KEY` |
-| Scopes | `gmail.readonly` + `userinfo.email` ONLY (tighter than george-gmail's full-workspace bundle) |
+| Authentication | bcrypt (cost 12) password + TOTP (otplib v12) — single-user; signup is locked after the first user is created |
+| 2FA enforcement | TOTP verified on every signin AND re-verified (step-up) within 60 seconds before any visit to `/import` or any "Import all" run |
+| OAuth | server-side authorization-code flow against Google; scopes: `gmail.readonly`, `drive.readonly`, `userinfo.email` |
+| Bank/card data | Plaid (sandbox by default); access tokens AES-256-GCM-encrypted at rest; **never** PAN/CVV/full account numbers — Plaid stores tokenized item refs only |
+| At-rest encryption | refresh tokens, Plaid access tokens, and TOTP secrets all AES-256-GCM under `ENCRYPTION_KEY` (32-byte hex, route via `/secrets`) |
+| Sessions | DB-backed (`auth_session` table), 30-day TTL, signed cookie `aos.sid`, httpOnly + sameSite=lax |
| Data residency | local PG `abrams_os` on Mac2; nothing in cloud yet |
-| PII handling | no redaction yet — single-user; do NOT add multi-tenant until redaction lands |
+| PII handling | no redaction yet — single-user; **do NOT** open up multi-tenant until redaction lands |
| Model providers | local Ollama qwen3:14b on Mac1 only; **no Anthropic API**, no OpenAI calls |
| HIPAA | N/A — no PHI ingested in v0; medical_* tables not implemented |
-| Audit log | every connector mutation writes one `audit_log` row |
+| Audit log | every connector mutation writes one `audit_log` row; every auth/import event writes one `auth_event` row |
| Outbound | none — drafts only; no email send, no API submit, no e-sign |
| Logging | pm2 stdout/stderr to `logs/`; no remote log shipping |
| Backups | manual `pg_dump` for now |
+| Plaid env | `PLAID_ENV=sandbox` by default; sandbox creds `user_good` / `pass_good`; promote to `development` then `production` only after Plaid Production Application Review |
## When this changes (and what becomes mandatory)
diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..fbc94e2
--- /dev/null
+++ b/lib/auth.js
@@ -0,0 +1,146 @@
+const bcrypt = require('bcrypt');
+const crypto = require('crypto');
+const { authenticator } = require('otplib');
+const QRCode = require('qrcode');
+
+const db = require('./db');
+const { encrypt, decrypt } = require('./crypto');
+const { id } = require('./ids');
+
+const SESSION_COOKIE = 'aos.sid';
+const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+const STEP_UP_TTL_MS = 60 * 1000; // 60 seconds
+const BCRYPT_ROUNDS = 12;
+
+authenticator.options = { window: 1 }; // accept ±30s drift
+
+// ─── password ────────────────────────────────────────────
+async function hashPassword(plain) {
+ if (!plain || plain.length < 12) throw new Error('password must be ≥12 chars');
+ return bcrypt.hash(plain, BCRYPT_ROUNDS);
+}
+async function verifyPassword(plain, hash) {
+ return bcrypt.compare(plain, hash);
+}
+
+// ─── TOTP ────────────────────────────────────────────────
+function generateTotpSecret() {
+ return authenticator.generateSecret();
+}
+function verifyTotp(token, secret) {
+ if (!token) return false;
+ return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret });
+}
+function totpUri(secret, accountName, issuer = 'AbramsOS') {
+ return authenticator.keyuri(accountName, issuer, secret);
+}
+async function totpQrDataUrl(uri) {
+ return QRCode.toDataURL(uri);
+}
+
+// ─── single-user lockout ────────────────────────────────
+async function userCount() {
+ const r = await db.query('SELECT count(*)::int AS n FROM auth_credential');
+ return r.rows[0].n;
+}
+
+// ─── sessions ────────────────────────────────────────────
+function newSessionId() {
+ return crypto.randomBytes(32).toString('base64url');
+}
+
+async function createSession(userId, { ip = null, userAgent = null } = {}) {
+ const sid = newSessionId();
+ const expires = new Date(Date.now() + SESSION_TTL_MS);
+ await db.query(
+ `INSERT INTO auth_session (id, user_id, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
+ [sid, userId, expires, ip, userAgent]
+ );
+ return { sid, expiresAt: expires };
+}
+
+async function loadSession(sid) {
+ if (!sid) return null;
+ const r = await db.query(
+ `SELECT id, user_id, step_up_at, expires_at, revoked_at FROM auth_session WHERE id = $1`,
+ [sid]
+ );
+ if (!r.rows.length) return null;
+ const s = r.rows[0];
+ if (s.revoked_at) return null;
+ if (new Date(s.expires_at).getTime() < Date.now()) return null;
+ return s;
+}
+
+async function markStepUp(sid) {
+ await db.query(`UPDATE auth_session SET step_up_at = now() WHERE id = $1`, [sid]);
+}
+
+function isStepUpValid(session) {
+ if (!session?.step_up_at) return false;
+ const age = Date.now() - new Date(session.step_up_at).getTime();
+ return age < STEP_UP_TTL_MS;
+}
+
+async function revokeSession(sid) {
+ await db.query(`UPDATE auth_session SET revoked_at = now() WHERE id = $1`, [sid]);
+}
+
+// ─── audit ──────────────────────────────────────────────
+async function event({ userId = null, sessionId = null, eventType, ip = null, userAgent = null, metadata = {} }) {
+ await db.query(
+ `INSERT INTO auth_event (user_id, session_id, event_type, ip, user_agent, metadata_jsonb)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [userId, sessionId, eventType, ip, userAgent, metadata]
+ );
+}
+
+// ─── credential ops ─────────────────────────────────────
+async function setPassword(userId, plain) {
+ const hash = await hashPassword(plain);
+ await db.query(
+ `INSERT INTO auth_credential (id, user_id, password_hash) VALUES ($1, $2, $3)
+ ON CONFLICT (user_id) DO UPDATE SET password_hash = EXCLUDED.password_hash, rotated_at = now()`,
+ [id('user'), userId, hash]
+ );
+}
+
+async function getCredential(userId) {
+ const r = await db.query(`SELECT password_hash FROM auth_credential WHERE user_id = $1`, [userId]);
+ return r.rows[0] || null;
+}
+
+async function setTotpSecret(userId, secret) {
+ const enc = encrypt(secret);
+ await db.query(
+ `INSERT INTO auth_totp (id, user_id, secret_encrypted, secret_iv, secret_tag) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (user_id) DO UPDATE SET secret_encrypted = EXCLUDED.secret_encrypted, secret_iv = EXCLUDED.secret_iv, secret_tag = EXCLUDED.secret_tag, enrolled_at = NULL`,
+ [id('user'), userId, enc.ciphertext, enc.iv, enc.tag]
+ );
+}
+
+async function getTotpSecret(userId) {
+ const r = await db.query(
+ `SELECT secret_encrypted, secret_iv, secret_tag, enrolled_at FROM auth_totp WHERE user_id = $1`,
+ [userId]
+ );
+ if (!r.rows.length) return null;
+ const row = r.rows[0];
+ const secret = decrypt(row.secret_encrypted, row.secret_iv, row.secret_tag);
+ return { secret, enrolled: !!row.enrolled_at };
+}
+
+async function markTotpEnrolled(userId) {
+ await db.query(`UPDATE auth_totp SET enrolled_at = COALESCE(enrolled_at, now()) WHERE user_id = $1`, [userId]);
+}
+
+module.exports = {
+ SESSION_COOKIE, SESSION_TTL_MS, STEP_UP_TTL_MS,
+ hashPassword, verifyPassword,
+ generateTotpSecret, verifyTotp, totpUri, totpQrDataUrl,
+ userCount,
+ newSessionId, createSession, loadSession, markStepUp, isStepUpValid, revokeSession,
+ event,
+ setPassword, getCredential,
+ setTotpSecret, getTotpSecret, markTotpEnrolled,
+};
diff --git a/lib/connectors-catalog.js b/lib/connectors-catalog.js
new file mode 100644
index 0000000..302321b
--- /dev/null
+++ b/lib/connectors-catalog.js
@@ -0,0 +1,50 @@
+// Catalog of supported connectors. Each entry describes how to connect, sync, and display the source.
+// The /import page renders one card per connector. The "Import all" button fans out to every
+// connected connector's sync endpoint.
+
+const CATALOG = [
+ {
+ key: 'gmail',
+ name: 'Gmail',
+ icon: 'G',
+ description: 'Order confirmations, receipts, shipping notices, warranty PDFs.',
+ provider: 'google',
+ syncPath: (id) => `/api/connectors/${id}/sync`,
+ connectPath: '/auth/google/start',
+ },
+ {
+ key: 'gdrive',
+ name: 'Google Drive',
+ icon: 'D',
+ description: 'Receipts, invoices, warranty PDFs you saved to Drive.',
+ provider: 'google',
+ syncPath: (id) => `/api/connectors/${id}/sync`,
+ connectPath: '/auth/google/start', // same OAuth, drive.readonly scope included
+ sharedWith: 'gmail', // bound by the same Google account
+ },
+ {
+ key: 'plaid',
+ name: 'Bank & Credit Cards (Plaid)',
+ icon: '$',
+ description: 'Amex, Wells Fargo, Chase, Citi, Cap One, Discover, BofA, and 200+ US institutions. Tokenized — no PAN/CVV ever leaves Plaid.',
+ provider: 'plaid',
+ syncPath: (id) => `/api/connectors/plaid/${id}/sync`,
+ connectPath: '/api/plaid/link/token', // returns Link token; frontend opens Plaid Link UI
+ requiresStepUp: true,
+ },
+ {
+ key: 'manual',
+ name: 'Manual upload',
+ icon: '↑',
+ description: 'Drag-drop OFX, QFX, CSV, or PDF statements from any bank.',
+ provider: 'manual',
+ syncPath: null, // upload is the sync
+ connectPath: '/import#manual-upload',
+ deferred: true, // not implemented in v0
+ },
+];
+
+function byKey(key) { return CATALOG.find(c => c.key === key); }
+function byProvider(provider) { return CATALOG.filter(c => c.provider === provider); }
+
+module.exports = { CATALOG, byKey, byProvider };
diff --git a/lib/google-oauth.js b/lib/google-oauth.js
index 020ced7..4f7d453 100644
--- a/lib/google-oauth.js
+++ b/lib/google-oauth.js
@@ -2,6 +2,7 @@ const { google } = require('googleapis');
const SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
+ 'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/userinfo.email',
];
diff --git a/lib/plaid-client.js b/lib/plaid-client.js
new file mode 100644
index 0000000..fb453be
--- /dev/null
+++ b/lib/plaid-client.js
@@ -0,0 +1,22 @@
+const { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } = require('plaid');
+
+function client() {
+ const env = process.env.PLAID_ENV || 'sandbox';
+ const clientId = process.env.PLAID_CLIENT_ID;
+ const secret = process.env.PLAID_SECRET;
+ if (!clientId || !secret) {
+ throw new Error('Plaid not configured. Set PLAID_CLIENT_ID + PLAID_SECRET (and optional PLAID_ENV) via /secrets. Sandbox creds are free at https://dashboard.plaid.com.');
+ }
+ const cfg = new Configuration({
+ basePath: PlaidEnvironments[env],
+ baseOptions: {
+ headers: { 'PLAID-CLIENT-ID': clientId, 'PLAID-SECRET': secret },
+ },
+ });
+ return new PlaidApi(cfg);
+}
+
+const PRODUCTS = [Products.Transactions];
+const COUNTRIES = [CountryCode.Us];
+
+module.exports = { client, PRODUCTS, COUNTRIES };
diff --git a/middleware/auth.js b/middleware/auth.js
new file mode 100644
index 0000000..92b999e
--- /dev/null
+++ b/middleware/auth.js
@@ -0,0 +1,32 @@
+const auth = require('../lib/auth');
+
+// Hydrate req.session + req.userId from the cookie
+async function loadSessionMiddleware(req, _res, next) {
+ const sid = req.cookies?.[auth.SESSION_COOKIE] || req.signedCookies?.[auth.SESSION_COOKIE];
+ if (!sid) return next();
+ const session = await auth.loadSession(sid);
+ if (!session) return next();
+ req.session = session;
+ req.userId = session.user_id;
+ next();
+}
+
+// Reject unauthenticated requests; redirect HTML to /signin, JSON gets 401
+function requireAuth(req, res, next) {
+ if (req.userId) return next();
+ if (req.accepts('html')) return res.redirect('/signin?next=' + encodeURIComponent(req.originalUrl));
+ return res.status(401).json({ error: 'unauthenticated' });
+}
+
+// Require recent (within 60s) successful TOTP verify
+function requireStepUp(req, res, next) {
+ if (!req.userId) {
+ if (req.accepts('html')) return res.redirect('/signin?next=' + encodeURIComponent(req.originalUrl));
+ return res.status(401).json({ error: 'unauthenticated' });
+ }
+ if (auth.isStepUpValid(req.session)) return next();
+ if (req.accepts('html')) return res.redirect('/step-up?next=' + encodeURIComponent(req.originalUrl));
+ return res.status(403).json({ error: 'step_up_required' });
+}
+
+module.exports = { loadSessionMiddleware, requireAuth, requireStepUp };
diff --git a/package-lock.json b/package-lock.json
index 67f8334..66c82b6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,8 @@
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
+ "bcrypt": "^6.0.0",
+ "cookie-parser": "^1.4.7",
"cookie-session": "^2.1.0",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
@@ -16,7 +18,10 @@
"googleapis": "^144.0.0",
"helmet": "^8.0.0",
"morgan": "^1.10.0",
+ "otplib": "^12.0.1",
"pg": "^8.13.0",
+ "plaid": "^42.2.0",
+ "qrcode": "^1.5.4",
"ulid": "^2.3.0"
},
"devDependencies": {
@@ -26,6 +31,56 @@
"node": ">=18"
}
},
+ "node_modules/@otplib/core": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
+ "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
+ "license": "MIT"
+ },
+ "node_modules/@otplib/plugin-crypto": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
+ "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
+ "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1"
+ }
+ },
+ "node_modules/@otplib/plugin-thirty-two": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
+ "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
+ "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "thirty-two": "^1.0.2"
+ }
+ },
+ "node_modules/@otplib/preset-default": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
+ "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
+ "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "@otplib/plugin-crypto": "^12.0.1",
+ "@otplib/plugin-thirty-two": "^12.0.1"
+ }
+ },
+ "node_modules/@otplib/preset-v11": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
+ "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "@otplib/plugin-crypto": "^12.0.1",
+ "@otplib/plugin-thirty-two": "^12.0.1"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -48,6 +103,30 @@
"node": ">= 14"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -74,6 +153,23 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
+ "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -118,6 +214,20 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -260,6 +370,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -285,6 +404,47 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -315,6 +475,25 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
"node_modules/cookie-session": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz",
@@ -358,6 +537,24 @@
"ms": "^2.1.1"
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -377,6 +574,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -433,6 +636,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -472,6 +681,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -609,6 +833,55 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -681,6 +954,15 @@
"node": ">=14"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -834,6 +1116,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -962,6 +1259,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1056,6 +1362,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1195,6 +1513,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
+ "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -1215,6 +1542,17 @@
}
}
},
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -1344,6 +1682,53 @@
"node": ">= 0.8"
}
},
+ "node_modules/otplib": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
+ "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "^12.0.1",
+ "@otplib/preset-default": "^12.0.1",
+ "@otplib/preset-v11": "^12.0.1"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1353,6 +1738,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1467,6 +1861,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/plaid": {
+ "version": "42.2.0",
+ "resolved": "https://registry.npmjs.org/plaid/-/plaid-42.2.0.tgz",
+ "integrity": "sha512-5SI9gobmmN1UYCpk80yOGB6Kb391bKvssKa4TuIP4G9z/+nBKH7z+j32gK8TH/rr41RGMVvC3ISauKe7uK2Jyg==",
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.7.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1519,6 +1934,15 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -1526,6 +1950,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -1578,6 +2019,21 @@
"node": ">=8.10.0"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1671,6 +2127,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1780,6 +2242,32 @@
"node": ">= 0.8"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1793,6 +2281,14 @@
"node": ">=4"
}
},
+ "node_modules/thirty-two": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
+ "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
+ "engines": {
+ "node": ">=0.2.6"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1932,6 +2428,26 @@
"webidl-conversions": "^3.0.0"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -1940,6 +2456,47 @@
"engines": {
"node": ">=0.4"
}
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
}
}
}
diff --git a/package.json b/package.json
index 6e3e327..68f5320 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
"seed": "psql -d abrams_os -f db/seed.sql"
},
"dependencies": {
+ "bcrypt": "^6.0.0",
+ "cookie-parser": "^1.4.7",
"cookie-session": "^2.1.0",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
@@ -18,7 +20,10 @@
"googleapis": "^144.0.0",
"helmet": "^8.0.0",
"morgan": "^1.10.0",
+ "otplib": "^12.0.1",
"pg": "^8.13.0",
+ "plaid": "^42.2.0",
+ "qrcode": "^1.5.4",
"ulid": "^2.3.0"
},
"devDependencies": {
diff --git a/public/css/app.css b/public/css/app.css
index ca78802..331a726 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -248,6 +248,84 @@ footer {
nav { order: 2; width: 100%; }
}
+/* ─── auth + forms ─── */
+.auth-card { max-width: 460px; margin: 60px auto; padding: 30px 32px; }
+.auth-card h1 { margin: 0 0 6px 0; font-size: 22px; letter-spacing: -0.01em; }
+.auth-card .subtle { color: var(--text-dim); margin: 0 0 18px 0; font-size: 13px; }
+
+form label {
+ display: flex; flex-direction: column; gap: 6px;
+ font-size: 12px; color: var(--text-dim); margin-bottom: 14px;
+}
+form input[type=email],
+form input[type=password],
+form input[type=text] {
+ background: rgba(0,0,0,0.18);
+ color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ padding: 10px 12px;
+ font-size: 15px;
+ font-family: inherit;
+}
+html[data-theme="light"] form input { background: rgba(255,255,255,0.7); }
+form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(125,211,252,0.18); }
+
+.error-banner {
+ background: rgba(248, 113, 113, 0.10);
+ border: 1px solid rgba(248, 113, 113, 0.35);
+ color: var(--danger);
+ padding: 8px 12px;
+ border-radius: 8px;
+ font-size: 13px;
+ margin: 0 0 16px 0;
+}
+
+.totp-enroll {
+ display: flex; gap: 16px; align-items: flex-start;
+ background: rgba(0,0,0,0.18);
+ padding: 14px;
+ border-radius: var(--radius-sm);
+ margin: 8px 0 18px 0;
+}
+.totp-enroll .qr { width: 160px; height: 160px; border-radius: 8px; background: white; padding: 6px; }
+.totp-secret summary { cursor: pointer; font-size: 12px; color: var(--text-dim); }
+.totp-secret code { display: block; margin-top: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; word-break: break-all; }
+
+/* ─── import dashboard ─── */
+.import-hero { padding: 28px 30px; }
+.import-hero h1 { margin: 0 0 6px 0; font-size: 26px; letter-spacing: -0.02em; }
+.import-hero p { color: var(--text-dim); margin: 0 0 18px 0; }
+.import-all-btn {
+ font-size: 16px; padding: 14px 28px;
+ background: linear-gradient(180deg, var(--accent), var(--accent-strong));
+ color: #04222f; font-weight: 700;
+ border: none; border-radius: 999px;
+ cursor: pointer;
+ box-shadow: 0 8px 24px rgba(56, 189, 248, 0.28);
+ transition: transform 80ms ease, box-shadow 120ms ease;
+}
+.import-all-btn:hover { transform: translateY(-1px); box-shadow: 0 12px 32px rgba(56, 189, 248, 0.36); }
+.import-all-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.import-status { font-size: 13px; color: var(--text-dim); margin-top: 14px; min-height: 18px; }
+
+.connector-grid { display: grid; gap: var(--space); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
+.connector-card { padding: 18px 20px; display: flex; flex-direction: column; gap: 10px; }
+.connector-card header { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
+.connector-card .name { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; }
+.connector-card .icon { width: 28px; height: 28px; border-radius: 8px; background: var(--surface-strong); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12px; }
+.connector-card .desc { font-size: 12px; color: var(--text-dim); margin: 0; }
+.connector-card .status { font-size: 11px; padding: 3px 9px; border-radius: 999px; }
+.connector-card .status.connected { background: rgba(134, 239, 172, 0.15); color: var(--ok); }
+.connector-card .status.not_connected { background: rgba(255,255,255,0.06); color: var(--text-dim); }
+.connector-card .status.syncing { background: rgba(125, 211, 252, 0.15); color: var(--accent); }
+.connector-card .status.error { background: rgba(248, 113, 113, 0.15); color: var(--danger); }
+.connector-card .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+
+.signout-btn { background: transparent; border: 1px solid var(--border-strong); color: var(--text-dim); padding: 4px 10px; border-radius: 999px; font-size: 12px; cursor: pointer; }
+.signout-btn:hover { color: var(--text); }
+
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
diff --git a/routes/auth-app.js b/routes/auth-app.js
new file mode 100644
index 0000000..4cd881f
--- /dev/null
+++ b/routes/auth-app.js
@@ -0,0 +1,170 @@
+const express = require('express');
+const auth = require('../lib/auth');
+const db = require('../lib/db');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+function clientMeta(req) {
+ return {
+ ip: (req.headers['x-forwarded-for']?.split(',')[0].trim() || req.ip || null),
+ userAgent: req.headers['user-agent'] || null,
+ };
+}
+
+// ─── signup (single-user, locked after first) ───────────
+router.get('/signup', async (_req, res) => {
+ const n = await auth.userCount();
+ if (n > 0) return res.redirect('/signin');
+ res.render('signup', { error: null });
+});
+
+router.post('/signup', async (req, res) => {
+ const n = await auth.userCount();
+ if (n > 0) return res.redirect('/signin');
+
+ const { email, password } = req.body || {};
+ if (!email || !password) return res.render('signup', { error: 'email + password required' });
+ if (password.length < 12) return res.render('signup', { error: 'password must be at least 12 characters' });
+
+ try {
+ // Upsert by id so the seeded row gets the user-submitted email
+ await db.query(
+ `INSERT INTO user_account (id, email, display_name) VALUES ($1, $2, $3)
+ ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name`,
+ [DEV_USER_ID, email, email.split('@')[0]]
+ );
+ await auth.setPassword(DEV_USER_ID, password);
+
+ // Generate the TOTP secret immediately and force enrollment on next page
+ const secret = auth.generateTotpSecret();
+ await auth.setTotpSecret(DEV_USER_ID, secret);
+
+ const meta = clientMeta(req);
+ await auth.event({ userId: DEV_USER_ID, eventType: 'signup', ...meta, metadata: { email } });
+
+ // Sign them into a temporary session so they can complete TOTP enrollment
+ const { sid } = await auth.createSession(DEV_USER_ID, meta);
+ res.cookie(auth.SESSION_COOKIE, sid, { httpOnly: true, sameSite: 'lax', maxAge: auth.SESSION_TTL_MS });
+ res.redirect('/enroll-totp');
+ } catch (err) {
+ console.error('[signup]', err);
+ res.render('signup', { error: err.message });
+ }
+});
+
+// ─── TOTP enrollment ────────────────────────────────────
+router.get('/enroll-totp', async (req, res) => {
+ if (!req.userId) return res.redirect('/signin');
+ const totp = await auth.getTotpSecret(req.userId);
+ if (!totp) return res.redirect('/signup');
+ if (totp.enrolled) return res.redirect('/');
+
+ const userR = await db.query(`SELECT email FROM user_account WHERE id = $1`, [req.userId]);
+ const email = userR.rows[0]?.email || 'user';
+ const uri = auth.totpUri(totp.secret, email);
+ const qr = await auth.totpQrDataUrl(uri);
+ res.render('enroll-totp', { qr, secret: totp.secret, error: null });
+});
+
+router.post('/enroll-totp', async (req, res) => {
+ if (!req.userId) return res.redirect('/signin');
+ const totp = await auth.getTotpSecret(req.userId);
+ if (!totp) return res.redirect('/signup');
+
+ const { token } = req.body || {};
+ if (!auth.verifyTotp(token, totp.secret)) {
+ const userR = await db.query(`SELECT email FROM user_account WHERE id = $1`, [req.userId]);
+ const email = userR.rows[0]?.email || 'user';
+ const uri = auth.totpUri(totp.secret, email);
+ const qr = await auth.totpQrDataUrl(uri);
+ return res.render('enroll-totp', { qr, secret: totp.secret, error: 'code did not match — try again' });
+ }
+
+ await auth.markTotpEnrolled(req.userId);
+ await auth.markStepUp(req.session.id);
+ await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'totp_enroll', ...clientMeta(req) });
+ res.redirect('/');
+});
+
+// ─── signin (password → TOTP → session) ─────────────────
+router.get('/signin', async (req, res) => {
+ if (req.userId) return res.redirect('/');
+ const n = await auth.userCount();
+ if (n === 0) return res.redirect('/signup');
+ res.render('signin', { stage: 'password', error: null, next: req.query.next || '/' });
+});
+
+router.post('/signin', async (req, res) => {
+ const meta = clientMeta(req);
+ const next = req.body?.next || '/';
+
+ // Stage 1: password
+ if (req.body?.stage === 'password' || !req.body?.stage) {
+ const { email, password } = req.body || {};
+ const userR = await db.query(`SELECT id FROM user_account WHERE email = $1`, [email || '']);
+ const cred = userR.rows[0] ? await auth.getCredential(userR.rows[0].id) : null;
+ const ok = cred ? await auth.verifyPassword(password || '', cred.password_hash) : false;
+
+ if (!ok) {
+ await auth.event({ userId: userR.rows[0]?.id || null, eventType: 'signin_pwd_fail', ...meta, metadata: { email } });
+ // constant-time-ish failure
+ return res.render('signin', { stage: 'password', error: 'invalid email or password', next });
+ }
+
+ const userId = userR.rows[0].id;
+ await auth.event({ userId, eventType: 'signin_pwd_ok', ...meta });
+
+ // Begin a session marked as NOT step-upped yet
+ const { sid } = await auth.createSession(userId, meta);
+ res.cookie(auth.SESSION_COOKIE, sid, { httpOnly: true, sameSite: 'lax', maxAge: auth.SESSION_TTL_MS });
+ return res.render('signin', { stage: 'totp', error: null, next });
+ }
+
+ // Stage 2: TOTP
+ if (req.body?.stage === 'totp') {
+ if (!req.userId) return res.redirect('/signin');
+ const totp = await auth.getTotpSecret(req.userId);
+ if (!totp || !totp.enrolled) return res.redirect('/enroll-totp');
+ const { token } = req.body || {};
+ if (!auth.verifyTotp(token, totp.secret)) {
+ await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'totp_fail', ...meta });
+ return res.render('signin', { stage: 'totp', error: 'invalid code', next });
+ }
+ await auth.markStepUp(req.session.id);
+ await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'totp_ok', ...meta });
+ return res.redirect(next);
+ }
+
+ res.redirect('/signin');
+});
+
+router.post('/signout', async (req, res) => {
+ if (req.session?.id) {
+ await auth.revokeSession(req.session.id);
+ await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'signout', ...clientMeta(req) });
+ }
+ res.clearCookie(auth.SESSION_COOKIE);
+ res.redirect('/signin');
+});
+
+// ─── step-up re-verify (for sensitive actions) ──────────
+router.get('/step-up', (req, res) => {
+ if (!req.userId) return res.redirect('/signin');
+ res.render('step-up', { error: null, next: req.query.next || '/' });
+});
+
+router.post('/step-up', async (req, res) => {
+ if (!req.userId) return res.redirect('/signin');
+ const totp = await auth.getTotpSecret(req.userId);
+ const next = req.body?.next || '/';
+ if (!totp || !auth.verifyTotp(req.body?.token, totp.secret)) {
+ await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'step_up_fail', ...clientMeta(req) });
+ return res.render('step-up', { error: 'invalid code', next });
+ }
+ await auth.markStepUp(req.session.id);
+ await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'step_up_ok', ...clientMeta(req) });
+ res.redirect(next);
+});
+
+module.exports = router;
diff --git a/routes/import.js b/routes/import.js
new file mode 100644
index 0000000..4c10d8c
--- /dev/null
+++ b/routes/import.js
@@ -0,0 +1,79 @@
+// /import — gated by requireStepUp (mounted at /import in server.js).
+// Renders per-connector status cards and an "Import all" button.
+// The button calls /import/run which fans out to every connected connector's sync endpoint.
+
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { CATALOG } = require('../lib/connectors-catalog');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function getConnectorStatus() {
+ const r = await db.query(
+ `SELECT id, provider, external_subject_id, last_sync_at,
+ (SELECT count(*) FROM source_message sm WHERE sm.connector_id = ca.id)::int AS message_count
+ FROM connector_account ca
+ WHERE ca.user_id = $1 AND (SELECT revoked_at FROM consent_grant cg WHERE cg.id = ca.consent_grant_id) IS NULL
+ ORDER BY ca.created_at DESC`,
+ [DEV_USER_ID]
+ );
+ return r.rows;
+}
+
+router.get('/', async (_req, res) => {
+ const accounts = await getConnectorStatus();
+ // Build connector-card view: catalog × actual accounts
+ const cards = CATALOG.map(c => {
+ const matched = accounts.filter(a => a.provider === c.provider);
+ return {
+ ...c,
+ accounts: matched,
+ connected: matched.length > 0,
+ };
+ });
+ res.render('import', { cards, plaidEnv: process.env.PLAID_ENV || 'sandbox' });
+});
+
+// Fan-out runner: trigger every connected connector's sync endpoint in parallel.
+router.post('/run', async (req, res) => {
+ await audit.log({
+ actorType: 'user',
+ actorId: DEV_USER_ID,
+ objectType: 'user_account',
+ objectId: DEV_USER_ID,
+ eventType: 'import_initiated',
+ metadata: { source: 'import_all_button', ip: req.ip },
+ });
+
+ const accounts = await getConnectorStatus();
+ if (!accounts.length) return res.json({ ok: true, message: 'no connectors to sync', results: [] });
+
+ // Use absolute URL so we hit the same Express via HTTP (preserves middleware + audit).
+ // Cookie-forward for auth + step-up.
+ const base = `http://127.0.0.1:${process.env.PORT || 9931}`;
+ const cookie = req.headers.cookie || '';
+
+ const results = await Promise.all(
+ accounts.map(async (a) => {
+ const path = a.provider === 'plaid'
+ ? `/api/connectors/plaid/${a.id}/sync`
+ : `/api/connectors/${a.id}/sync`;
+ try {
+ const r = await fetch(`${base}${path}`, {
+ method: 'POST',
+ headers: { cookie, 'Content-Type': 'application/json' },
+ });
+ const body = await r.json().catch(() => ({}));
+ return { connector_id: a.id, provider: a.provider, status: r.status, body };
+ } catch (err) {
+ return { connector_id: a.id, provider: a.provider, status: 0, error: err.message };
+ }
+ })
+ );
+
+ res.json({ ok: true, results });
+});
+
+module.exports = router;
diff --git a/routes/plaid.js b/routes/plaid.js
new file mode 100644
index 0000000..8e5c80e
--- /dev/null
+++ b/routes/plaid.js
@@ -0,0 +1,167 @@
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { encrypt, decrypt } = require('../lib/crypto');
+const plaidLib = require('../lib/plaid-client');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+// 1) Browser asks for a Link token; we hand one back, they open Plaid Link in their browser.
+router.post('/api/plaid/link/token', async (req, res) => {
+ try {
+ const c = plaidLib.client();
+ const r = await c.linkTokenCreate({
+ user: { client_user_id: req.userId || DEV_USER_ID },
+ client_name: 'AbramsOS',
+ products: plaidLib.PRODUCTS,
+ country_codes: plaidLib.COUNTRIES,
+ language: 'en',
+ });
+ res.json({ link_token: r.data.link_token, expiration: r.data.expiration });
+ } catch (err) {
+ console.error('[plaid link/token]', err.response?.data || err);
+ res.status(500).json({ error: err.response?.data?.error_message || err.message });
+ }
+});
+
+// 2) Plaid Link returns a public_token; we exchange it for an access_token and store encrypted.
+router.post('/api/plaid/exchange', async (req, res) => {
+ const { public_token, institution_name } = req.body || {};
+ if (!public_token) return res.status(400).json({ error: 'public_token required' });
+ try {
+ const c = plaidLib.client();
+ const r = await c.itemPublicTokenExchange({ public_token });
+ const accessToken = r.data.access_token;
+ const itemId = r.data.item_id;
+ const enc = encrypt(accessToken);
+
+ const consentId = id('consent');
+ const connectorId = id('connector');
+
+ await db.tx(async (client) => {
+ await client.query(
+ `INSERT INTO consent_grant (id, user_id, connector_type, scopes_json) VALUES ($1, $2, $3, $4)`,
+ [consentId, DEV_USER_ID, 'plaid', JSON.stringify({ products: ['transactions'], institution: institution_name || null })]
+ );
+ await client.query(
+ `INSERT INTO connector_account
+ (id, user_id, consent_grant_id, provider, external_subject_id,
+ refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
+ VALUES ($1, $2, $3, 'plaid', $4, $5, $6, $7)
+ ON CONFLICT (provider, external_subject_id) DO UPDATE SET
+ refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
+ refresh_token_iv = EXCLUDED.refresh_token_iv,
+ refresh_token_tag = EXCLUDED.refresh_token_tag,
+ consent_grant_id = EXCLUDED.consent_grant_id`,
+ [connectorId, DEV_USER_ID, consentId, itemId, enc.ciphertext, enc.iv, enc.tag]
+ );
+ });
+
+ await audit.log({
+ actorType: 'user',
+ actorId: DEV_USER_ID,
+ objectType: 'connector_account',
+ objectId: connectorId,
+ eventType: 'plaid_link_exchange',
+ metadata: { institution: institution_name || null, item_id: itemId },
+ });
+
+ res.json({ ok: true, connector_id: connectorId });
+ } catch (err) {
+ console.error('[plaid exchange]', err.response?.data || err);
+ res.status(500).json({ error: err.response?.data?.error_message || err.message });
+ }
+});
+
+// 3) Sync transactions for a Plaid connector → upsert into purchase rows.
+router.post('/api/connectors/plaid/:id/sync', async (req, res) => {
+ const connectorId = req.params.id;
+ const r = await db.query(
+ `SELECT refresh_token_encrypted, refresh_token_iv, refresh_token_tag
+ FROM connector_account WHERE id = $1 AND user_id = $2 AND provider = 'plaid'`,
+ [connectorId, DEV_USER_ID]
+ );
+ if (!r.rows.length) return res.status(404).json({ error: 'plaid connector not found' });
+ const accessToken = decrypt(r.rows[0].refresh_token_encrypted, r.rows[0].refresh_token_iv, r.rows[0].refresh_token_tag);
+
+ await audit.log({
+ actorType: 'user',
+ actorId: DEV_USER_ID,
+ objectType: 'connector_account',
+ objectId: connectorId,
+ eventType: 'sync_started',
+ });
+
+ let purchases = 0;
+ try {
+ const c = plaidLib.client();
+ // Use /transactions/sync (the modern incremental cursor endpoint)
+ let cursor = null;
+ let added = [];
+ let hasMore = true;
+ let calls = 0;
+ while (hasMore && calls < 5) { // cap at 5 pages per sync to stay under Plaid limits
+ const resp = await c.transactionsSync({ access_token: accessToken, cursor: cursor || undefined });
+ added = added.concat(resp.data.added || []);
+ cursor = resp.data.next_cursor;
+ hasMore = resp.data.has_more;
+ calls += 1;
+ }
+
+ for (const tx of added) {
+ // Plaid amounts are positive for outflow (purchase), negative for inflow (refund/deposit).
+ if (tx.amount == null || tx.amount <= 0) continue;
+ const purchaseId = id('purchase');
+ await db.query(
+ `INSERT INTO purchase
+ (id, user_id, source_message_id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence, raw_extract)
+ VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10)`,
+ [
+ purchaseId,
+ DEV_USER_ID,
+ tx.merchant_name || tx.name || 'Unknown',
+ tx.website || null,
+ tx.transaction_id, // Plaid's stable id
+ tx.date || tx.authorized_date || new Date(),
+ tx.amount,
+ tx.iso_currency_code || 'USD',
+ 0.95, // bank-statement-derived purchases are high-confidence
+ tx,
+ ]
+ );
+ purchases += 1;
+ await audit.log({
+ actorType: 'system',
+ objectType: 'purchase',
+ objectId: purchaseId,
+ eventType: 'purchase_extracted',
+ metadata: { source: 'plaid', merchant: tx.merchant_name || tx.name, total: tx.amount, plaid_tx_id: tx.transaction_id },
+ });
+ }
+
+ await db.query(`UPDATE connector_account SET last_sync_at = now() WHERE id = $1`, [connectorId]);
+ await audit.log({
+ actorType: 'system',
+ objectType: 'connector_account',
+ objectId: connectorId,
+ eventType: 'sync_completed',
+ metadata: { source: 'plaid', added: added.length, purchases },
+ });
+
+ res.json({ ok: true, fetched: added.length, purchases });
+ } catch (err) {
+ console.error('[plaid sync]', err.response?.data || err);
+ await audit.log({
+ actorType: 'system',
+ objectType: 'connector_account',
+ objectId: connectorId,
+ eventType: 'sync_failed',
+ metadata: { source: 'plaid', error: err.message },
+ });
+ res.status(500).json({ error: err.response?.data?.error_message || err.message });
+ }
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 2b42253..02af15c 100644
--- a/server.js
+++ b/server.js
@@ -3,48 +3,59 @@ const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const path = require('path');
-const cookieSession = require('cookie-session');
+const cookieParser = require('cookie-parser');
+
+const { loadSessionMiddleware, requireAuth, requireStepUp } = require('./middleware/auth');
const home = require('./routes/home');
const health = require('./routes/health');
-const auth = require('./routes/auth');
+const oauth = require('./routes/auth'); // Google OAuth (existing)
+const authApp = require('./routes/auth-app'); // password + TOTP signup/signin
const connectors = require('./routes/connectors');
const purchases = require('./routes/purchases');
+const importRouter = require('./routes/import');
+const plaidRouter = require('./routes/plaid');
const app = express();
const PORT = parseInt(process.env.PORT || '9931', 10);
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
+app.set('trust proxy', 'loopback');
-app.use(helmet({
- contentSecurityPolicy: false, // dev — tighten in M1 (Foundation)
- crossOriginEmbedderPolicy: false,
-}));
+app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
app.use(morgan('tiny'));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
+app.use(cookieParser(process.env.SESSION_SECRET || 'dev-only-rotate-me'));
-app.use(cookieSession({
- name: 'aos.sid',
- keys: [process.env.SESSION_SECRET || 'dev-only-rotate-me'],
- maxAge: 24 * 60 * 60 * 1000,
- sameSite: 'lax',
- httpOnly: true,
- secure: false, // dev http
-}));
+// Load DB-backed session for every request (no-op if cookie missing)
+app.use(loadSessionMiddleware);
-// Make INFO_EMAIL available to all views
+// View locals
app.locals.infoEmail = process.env.INFO_EMAIL || 'info@abramsos.agentabrams.com';
-app.use((req, res, next) => { res.locals.infoEmail = app.locals.infoEmail; next(); });
+app.use((req, res, next) => {
+ res.locals.infoEmail = app.locals.infoEmail;
+ res.locals.userId = req.userId || null;
+ next();
+});
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '0' }));
-app.use(health);
-app.use(home);
-app.use(auth);
-app.use(connectors);
-app.use(purchases);
+// Public routes
+app.use(health); // /healthz
+app.use(authApp); // /signup, /signin, /signout, /enroll-totp, /step-up
+
+// Authenticated routes (require login)
+app.use(requireAuth);
+app.use(home); // /
+app.use(oauth); // /auth/google/start, /auth/google/callback
+app.use(connectors); // /connectors, /api/connectors, sync
+app.use(purchases); // /purchases, /api/purchases
+app.use(plaidRouter); // /api/plaid/*
+
+// Step-up-required routes (must re-verify TOTP within 60s)
+app.use('/import', requireStepUp, importRouter);
app.use((err, _req, res, _next) => {
console.error('[unhandled]', err);
diff --git a/tests/auth-e2e.test.js b/tests/auth-e2e.test.js
new file mode 100644
index 0000000..ff1c9e5
--- /dev/null
+++ b/tests/auth-e2e.test.js
@@ -0,0 +1,132 @@
+// End-to-end: signup → enroll TOTP → signin (password+totp) → /import gate → step-up → /import 200.
+// Talks to a fresh in-process server.
+
+const test = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const { authenticator } = require('otplib');
+
+require('dotenv').config();
+
+// Wipe + reset relevant tables so the test is reproducible
+const { pool } = require('../lib/db');
+test.before(async () => {
+ await pool.query(`DELETE FROM auth_event`);
+ await pool.query(`DELETE FROM auth_session`);
+ await pool.query(`DELETE FROM auth_totp`);
+ await pool.query(`DELETE FROM auth_credential`);
+});
+
+const app = require('../server');
+let server;
+test.before(() => new Promise((r) => { server = app.listen(0, r); }));
+test.after(async () => {
+ await new Promise((r) => server.close(r));
+ await pool.end();
+});
+
+let cookieJar = '';
+
+function captureCookie(res) {
+ const set = res.headers['set-cookie'];
+ if (set) {
+ for (const c of set) {
+ const m = c.match(/^([^=]+)=([^;]*)/);
+ if (m && m[1] === 'aos.sid') cookieJar = `aos.sid=${m[2]}`;
+ if (m && m[1] === 'aos.sid' && m[2] === '') cookieJar = '';
+ }
+ }
+}
+
+function req(path, { method = 'GET', body = null, follow = false } = {}) {
+ return new Promise((resolve, reject) => {
+ const port = server.address().port;
+ const opts = {
+ method, hostname: '127.0.0.1', port, path,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Cookie': cookieJar || '',
+ 'Accept': 'text/html,application/json',
+ },
+ };
+ const r = http.request(opts, (res) => {
+ let data = ''; res.on('data', (c) => (data += c));
+ res.on('end', () => {
+ captureCookie(res);
+ resolve({ status: res.statusCode, body: data, headers: res.headers });
+ });
+ });
+ r.on('error', reject);
+ if (body) {
+ const enc = typeof body === 'string' ? body : new URLSearchParams(body).toString();
+ r.write(enc);
+ }
+ r.end();
+ });
+}
+
+test('signup creates user + sets cookie', async () => {
+ const r = await req('/signup', { method: 'POST', body: { email: 'steve+e2e@example.com', password: 'verystrongpassword!' } });
+ assert.strictEqual(r.status, 302);
+ assert.match(r.headers.location, /\/enroll-totp/);
+ assert.ok(cookieJar, 'session cookie should be set');
+});
+
+test('enroll-totp page shows the QR + secret', async () => {
+ const r = await req('/enroll-totp');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.body, /Enroll two-factor authentication/);
+});
+
+test('TOTP enrollment with valid code completes', async () => {
+ const sec = (await pool.query(`SELECT secret_encrypted, secret_iv, secret_tag FROM auth_totp`)).rows[0];
+ const { decrypt } = require('../lib/crypto');
+ const secret = decrypt(sec.secret_encrypted, sec.secret_iv, sec.secret_tag);
+ const token = authenticator.generate(secret);
+ const r = await req('/enroll-totp', { method: 'POST', body: { token } });
+ assert.strictEqual(r.status, 302);
+ assert.match(r.headers.location, /\//);
+});
+
+test('after enrollment, / returns 200', async () => {
+ const r = await req('/');
+ assert.strictEqual(r.status, 200);
+});
+
+test('after enrollment with valid step-up, /import returns 200', async () => {
+ const r = await req('/import');
+ // step-up just happened during TOTP enroll → within 60s window → should pass
+ assert.strictEqual(r.status, 200);
+ assert.match(r.body, /Import all receipts now/);
+});
+
+test('signout clears cookie + /import becomes 302 again', async () => {
+ await req('/signout', { method: 'POST' });
+ const r = await req('/import');
+ assert.strictEqual(r.status, 302);
+});
+
+test('full signin (password → totp) restores access', async () => {
+ // password
+ const r1 = await req('/signin', { method: 'POST', body: { stage: 'password', email: 'steve+e2e@example.com', password: 'verystrongpassword!', next: '/' } });
+ assert.strictEqual(r1.status, 200);
+ assert.match(r1.body, /Two-factor code/);
+
+ // totp
+ const sec = (await pool.query(`SELECT secret_encrypted, secret_iv, secret_tag FROM auth_totp`)).rows[0];
+ const { decrypt } = require('../lib/crypto');
+ const secret = decrypt(sec.secret_encrypted, sec.secret_iv, sec.secret_tag);
+ const token = authenticator.generate(secret);
+ const r2 = await req('/signin', { method: 'POST', body: { stage: 'totp', token, next: '/' } });
+ assert.strictEqual(r2.status, 302);
+ assert.match(r2.headers.location, /\//);
+
+ const r3 = await req('/');
+ assert.strictEqual(r3.status, 200);
+});
+
+test('signup is locked after first user', async () => {
+ const r = await req('/signup');
+ assert.strictEqual(r.status, 302);
+ assert.match(r.headers.location, /\/signin/);
+});
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 8149b93..8076350 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -35,20 +35,24 @@ test('healthz returns 200', async () => {
assert.strictEqual(j.ok, true);
});
-test('home renders', async () => {
+test('home is auth-gated', async () => {
const r = await get('/');
- assert.strictEqual(r.status, 200);
- assert.match(r.body, /AbramsOS/);
+ assert.strictEqual(r.status, 302);
+ assert.match(r.headers.location, /\/sign(in|up)/);
});
-test('connectors page renders', async () => {
+test('connectors is auth-gated', async () => {
const r = await get('/connectors');
- assert.strictEqual(r.status, 200);
- assert.match(r.body, /Connect/i);
+ assert.strictEqual(r.status, 302);
});
-test('purchases page renders', async () => {
+test('purchases is auth-gated', async () => {
const r = await get('/purchases');
- assert.strictEqual(r.status, 200);
- assert.match(r.body, /Purchases/);
+ assert.strictEqual(r.status, 302);
+});
+
+test('signup is public when no user exists, locked otherwise', async () => {
+ const r = await get('/signup');
+ // Either 200 (no user) or 302→/signin (user exists). Both are correct behavior.
+ assert.ok([200, 302].includes(r.status));
});
diff --git a/views/enroll-totp.ejs b/views/enroll-totp.ejs
new file mode 100644
index 0000000..dfd9293
--- /dev/null
+++ b/views/enroll-totp.ejs
@@ -0,0 +1,25 @@
+<%- include('partials/header', { title: 'Set up 2FA' }) %>
+
+<section class="auth-card glass">
+ <h1>Enroll two-factor authentication</h1>
+ <p class="subtle">Scan the QR with Google Authenticator, 1Password, Authy, or any TOTP app. Then enter the 6-digit code to confirm.</p>
+
+ <% if (error) { %><p class="error-banner"><%= error %></p><% } %>
+
+ <div class="totp-enroll">
+ <img class="qr" src="<%= qr %>" alt="TOTP QR code">
+ <details class="totp-secret">
+ <summary>Or enter the secret manually</summary>
+ <code><%= secret.match(/.{1,4}/g).join(' ') %></code>
+ </details>
+ </div>
+
+ <form method="post" action="/enroll-totp" autocomplete="off">
+ <label>6-digit code from your app
+ <input type="text" name="token" inputmode="numeric" pattern="[0-9 ]{6,8}" maxlength="8" required autofocus>
+ </label>
+ <button class="button primary" type="submit">Confirm 2FA</button>
+ </form>
+</section>
+
+<%- include('partials/footer') %>
diff --git a/views/import.ejs b/views/import.ejs
new file mode 100644
index 0000000..18923aa
--- /dev/null
+++ b/views/import.ejs
@@ -0,0 +1,132 @@
+<%- include('partials/header', { title: 'Import' }) %>
+
+<section class="import-hero glass">
+ <h1>Import receipts from everything you use</h1>
+ <p>One button. Pulls Gmail, Drive, and every linked bank or credit card via Plaid (Amex, Wells, Chase, Citi, Cap One, Discover, BofA — 200+ US institutions). Tokenized — your card numbers never touch AbramsOS.</p>
+ <button id="importAllBtn" class="import-all-btn" type="button">Import all receipts now</button>
+ <div id="importStatus" class="import-status"></div>
+ <p class="subtle" style="margin-top:18px">Two-factor verification is required before each import. Your code expires after 60 seconds.</p>
+</section>
+
+<section class="connector-grid">
+ <% cards.forEach(card => { %>
+ <article class="connector-card glass" data-key="<%= card.key %>">
+ <header>
+ <span class="name">
+ <span class="icon"><%= card.icon %></span>
+ <%= card.name %>
+ </span>
+ <span class="status <%= card.connected ? 'connected' : 'not_connected' %>">
+ <%= card.connected ? 'Connected' : 'Not connected' %>
+ </span>
+ </header>
+ <p class="desc"><%= card.description %></p>
+
+ <% if (card.deferred) { %>
+ <p class="subtle" style="font-size:11px">Coming in the next milestone.</p>
+ <% } else if (card.connected) { %>
+ <% card.accounts.forEach(a => { %>
+ <div class="row-actions" style="font-size:12px;color:var(--text-dim)">
+ <span><%= a.external_subject_id %></span>
+ <% if (a.last_sync_at) { %>
+ <span>· last sync <%= new Date(a.last_sync_at).toLocaleString() %></span>
+ <% } %>
+ <% if (a.message_count != null) { %>
+ <span>· <%= a.message_count %> msgs</span>
+ <% } %>
+ </div>
+ <% }) %>
+ <% if (card.provider === 'plaid') { %>
+ <div class="row-actions">
+ <button class="button" type="button" onclick="openPlaidLink()">Add another bank or card</button>
+ </div>
+ <% } %>
+ <% } else { %>
+ <% if (card.provider === 'google') { %>
+ <a class="button primary" href="<%= card.connectPath %>">Connect <%= card.name %></a>
+ <% } else if (card.provider === 'plaid') { %>
+ <button class="button primary" type="button" onclick="openPlaidLink()">Connect Plaid (sandbox)</button>
+ <p class="subtle" style="font-size:11px">Sandbox creds: user_good / pass_good</p>
+ <% } %>
+ <% } %>
+ </article>
+ <% }) %>
+</section>
+
+<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
+<script>
+(function(){
+ const status = document.getElementById('importStatus');
+ const btn = document.getElementById('importAllBtn');
+
+ async function importAll() {
+ btn.disabled = true;
+ status.textContent = 'Starting import…';
+ try {
+ const r = await fetch('/import/run', { method: 'POST' });
+ if (r.status === 403) {
+ // Step-up expired mid-page; bounce to step-up
+ location.href = '/step-up?next=' + encodeURIComponent('/import');
+ return;
+ }
+ const j = await r.json();
+ if (j.ok) {
+ const totals = (j.results || []).map(x =>
+ x.status === 200
+ ? `${x.provider}: ${x.body.purchases ?? 0} new`
+ : `${x.provider}: ${x.body?.error || 'error'}`
+ );
+ status.textContent = totals.length ? totals.join(' · ') : (j.message || 'no connectors');
+ } else {
+ status.textContent = 'Error: ' + (j.error || 'unknown');
+ }
+ } catch (e) {
+ status.textContent = 'Error: ' + e.message;
+ } finally {
+ btn.disabled = false;
+ setTimeout(() => location.reload(), 2200);
+ }
+ }
+ btn.addEventListener('click', importAll);
+
+ // Plaid Link
+ let plaidHandler = null;
+ window.openPlaidLink = async function () {
+ status.textContent = 'Opening Plaid Link…';
+ try {
+ const r = await fetch('/api/plaid/link/token', { method: 'POST' });
+ const j = await r.json();
+ if (!j.link_token) {
+ status.textContent = 'Plaid not configured: ' + (j.error || 'set PLAID_CLIENT_ID + PLAID_SECRET via /secrets');
+ return;
+ }
+ plaidHandler = Plaid.create({
+ token: j.link_token,
+ onSuccess: async (publicToken, metadata) => {
+ status.textContent = 'Linking ' + (metadata.institution?.name || 'institution') + '…';
+ const ex = await fetch('/api/plaid/exchange', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ public_token: publicToken, institution_name: metadata.institution?.name }),
+ });
+ const ej = await ex.json();
+ if (ej.ok) {
+ status.textContent = 'Connected. Click Import all to sync.';
+ setTimeout(() => location.reload(), 1500);
+ } else {
+ status.textContent = 'Plaid exchange failed: ' + (ej.error || 'unknown');
+ }
+ },
+ onExit: (err) => {
+ if (err) status.textContent = 'Plaid Link exited: ' + err.error_message;
+ },
+ });
+ plaidHandler.open();
+ } catch (e) {
+ status.textContent = 'Error: ' + e.message;
+ }
+ };
+})();
+</script>
+
+<%- include('partials/footer') %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index f69e3ee..15e94dd 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -22,9 +22,15 @@
</div>
<nav>
<a href="/">Home</a>
+ <a href="/import">Import</a>
<a href="/connectors">Connectors</a>
<a href="/purchases">Purchases</a>
</nav>
+ <% if (typeof userId !== 'undefined' && userId) { %>
+ <form method="post" action="/signout" style="margin:0">
+ <button class="signout-btn" type="submit">Sign out</button>
+ </form>
+ <% } %>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme" title="Toggle dark/light">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
diff --git a/views/signin.ejs b/views/signin.ejs
new file mode 100644
index 0000000..50de419
--- /dev/null
+++ b/views/signin.ejs
@@ -0,0 +1,33 @@
+<%- include('partials/header', { title: 'Sign in' }) %>
+
+<section class="auth-card glass">
+ <% if (stage === 'password') { %>
+ <h1>Sign in</h1>
+ <% if (error) { %><p class="error-banner"><%= error %></p><% } %>
+ <form method="post" action="/signin" autocomplete="off">
+ <input type="hidden" name="stage" value="password">
+ <input type="hidden" name="next" value="<%= next %>">
+ <label>Email
+ <input type="email" name="email" required autofocus>
+ </label>
+ <label>Password
+ <input type="password" name="password" required autocomplete="current-password">
+ </label>
+ <button class="button primary" type="submit">Continue</button>
+ </form>
+ <% } else if (stage === 'totp') { %>
+ <h1>Two-factor code</h1>
+ <p class="subtle">Open your authenticator app and enter the 6-digit code.</p>
+ <% if (error) { %><p class="error-banner"><%= error %></p><% } %>
+ <form method="post" action="/signin" autocomplete="off">
+ <input type="hidden" name="stage" value="totp">
+ <input type="hidden" name="next" value="<%= next %>">
+ <label>6-digit code
+ <input type="text" name="token" inputmode="numeric" pattern="[0-9 ]{6,8}" maxlength="8" required autofocus>
+ </label>
+ <button class="button primary" type="submit">Verify</button>
+ </form>
+ <% } %>
+</section>
+
+<%- include('partials/footer') %>
diff --git a/views/signup.ejs b/views/signup.ejs
new file mode 100644
index 0000000..dd5fc7a
--- /dev/null
+++ b/views/signup.ejs
@@ -0,0 +1,21 @@
+<%- include('partials/header', { title: 'Sign up' }) %>
+
+<section class="auth-card glass">
+ <h1>Welcome to AbramsOS</h1>
+ <p class="subtle">First-time setup. After this, signup is locked.</p>
+
+ <% if (error) { %><p class="error-banner"><%= error %></p><% } %>
+
+ <form method="post" action="/signup" autocomplete="off">
+ <label>Email
+ <input type="email" name="email" required autofocus>
+ </label>
+ <label>Password (min 12 chars)
+ <input type="password" name="password" required minlength="12" autocomplete="new-password">
+ </label>
+ <button class="button primary" type="submit">Create account</button>
+ <p class="subtle">After signup you'll enroll a TOTP authenticator (Google Authenticator, 1Password, Authy).</p>
+ </form>
+</section>
+
+<%- include('partials/footer') %>
diff --git a/views/step-up.ejs b/views/step-up.ejs
new file mode 100644
index 0000000..5d6f0de
--- /dev/null
+++ b/views/step-up.ejs
@@ -0,0 +1,18 @@
+<%- include('partials/header', { title: 'Re-verify' }) %>
+
+<section class="auth-card glass">
+ <h1>Re-verify to continue</h1>
+ <p class="subtle">For your security, AbramsOS requires a fresh 2FA code before importing personal data or adding a new connector.</p>
+
+ <% if (error) { %><p class="error-banner"><%= error %></p><% } %>
+
+ <form method="post" action="/step-up" autocomplete="off">
+ <input type="hidden" name="next" value="<%= next %>">
+ <label>6-digit code
+ <input type="text" name="token" inputmode="numeric" pattern="[0-9 ]{6,8}" maxlength="8" required autofocus>
+ </label>
+ <button class="button primary" type="submit">Verify</button>
+ </form>
+</section>
+
+<%- include('partials/footer') %>
← 434eca9 rename OwnershipOS → AbramsOS (per Steve): pkg/db/pm2/env/br
·
back to AbramsOS
·
tick 1: receipt extractor tier-2 LLM fallback (Mac1 qwen3:14 323f8a8 →