[object Object]

← 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

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 →