managarten/scripts/audit-workspace-deps.mjs
Till JS 8e8b6ac65f fix(mana-auth) + chore: rewrite /api/v1/auth/login JWT mint, remove Matrix stack
This commit bundles two unrelated changes that were swept together by an
accidental `git add -A` in another working session. Documented here so the
history reflects what's actually inside.

═══════════════════════════════════════════════════════════════════════
1. fix(mana-auth): /api/v1/auth/login mints JWT via auth.handler instead
   of api.signInEmail
═══════════════════════════════════════════════════════════════════════

Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint in
/api/v1/auth/login by switching the cookie name from `mana.session_token`
to `__Secure-mana.session_token` for production. That was necessary but
not sufficient: Better Auth's session cookie value isn't just the raw
session token, it's `<token>.<HMAC>` where the HMAC is derived from the
better-auth secret. Reconstructing the cookie from auth.api.signInEmail's
JSON response only gave us the raw token, so /api/auth/token's
get-session middleware still couldn't validate it and the JWT mint kept
silently failing.

Real fix: do the sign-in via auth.handler (the HTTP path) rather than
auth.api.signInEmail (the SDK path). The handler returns a real fetch
Response with a Set-Cookie header containing the fully signed cookie
envelope. We capture that header verbatim and forward it as the cookie
on the /api/auth/token request, which now passes validation and mints
the JWT correctly.

Verified end-to-end on auth.mana.how:

  $ curl -X POST https://auth.mana.how/api/v1/auth/login \
      -d '{"email":"...","password":"..."}'
  {
    "user": {...},
    "token": "<session token>",
    "accessToken": "eyJhbGciOiJFZERTQSI...",   ← real JWT now
    "refreshToken": "<session token>"
  }

Side benefits:
- Email-not-verified path is now handled by checking
  signInResponse.status === 403 directly, no more catching APIError
  with the comment-noted async-stream footgun.
- X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter
  and our security log see the real client IP.
- The leftover catch block now only handles unexpected exceptions
  (network errors etc); the FORBIDDEN-checking logic in it is dead but
  harmless and left in for defense in depth.

═══════════════════════════════════════════════════════════════════════
2. chore: remove the entire self-hosted Matrix stack (Synapse, Element,
   Manalink, mana-matrix-bot)
═══════════════════════════════════════════════════════════════════════

The Matrix subsystem ran parallel to the main Mana product without any
load-bearing integration: the unified web app never imported matrix-js-sdk,
the chat module uses mana-sync (local-first), and mana-matrix-bot's
plugins duplicated features the unified app already ships natively.
Keeping it alive cost a Synapse + Element + matrix-web + bot container
quartet, three Cloudflare routes, an OIDC provider plugin in mana-auth,
and a steady drip of devlog/dependency churn.

Removed:
- apps/matrix (Manalink web + mobile, ~150 files)
- services/mana-matrix-bot (Go bot with ~20 plugins)
- docker/matrix configs (Synapse + Element)
- synapse/element-web/matrix-web/mana-matrix-bot services in
  docker-compose.macmini.yml
- matrix.mana.how/element.mana.how/link.mana.how Cloudflare tunnel routes
- OIDC provider plugin + matrix-synapse trustedClient + matrixUserLinks
  table from mana-auth (oauth_* schema definitions also removed)
- MatrixService import path in mana-media (importFromMatrix endpoint)
- Matrix notification channel in mana-notify (worker, metrics, config,
  channel_type enum, MatrixOptions handler)
- Matrix entries from shared-branding (mana-apps + app-icons),
  notify-client, the i18n bundle, the observatory map, the credits
  app-label list, the landing footer/apps page, the prometheus + alerts
  + promtail tier mappings, and the matrix-related deploy paths in
  cd-macmini.yml + ci.yml

Devlog/manascore/blueprint entries that mention Matrix are left intact
as historical record. The oauth_* + matrix_user_links Postgres tables
stay on existing prod databases — code can no longer write to them, drop
them in a follow-up migration if you want them gone for real.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:32:13 +02:00

325 lines
8.3 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Audit Workspace Dependencies
*
* Finds SvelteKit web apps that import workspace packages (@mana/*, @project/shared, etc.)
* without declaring them in package.json. This works locally due to pnpm hoisting but breaks
* in Docker builds.
*
* Usage:
* node scripts/audit-workspace-deps.mjs # Report missing deps
* node scripts/audit-workspace-deps.mjs --fix # Auto-add missing deps and run pnpm install
*/
import { readFileSync, readdirSync, statSync, writeFileSync, existsSync } from 'fs';
import { join, relative, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MONOREPO_ROOT = join(__dirname, '..');
const FIX_MODE = process.argv.includes('--fix');
// Colors
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
// Directories to skip when scanning source files
const SKIP_DIRS = new Set(['node_modules', 'dist', '.svelte-kit', 'build', '.turbo', '.vercel']);
// File extensions to scan
const SCAN_EXTENSIONS = new Set(['.ts', '.svelte', '.js']);
// Regex to match workspace package imports
// Matches: @mana/*, @calendar/shared, @todo/shared, @zitare/content, etc.
const IMPORT_REGEX =
/(?:import\s+(?:[\s\S]*?\s+from\s+)?|import\s*\()\s*['"](@[a-z-]+\/[a-z-]+)(?:\/[^'"]*)?['"]/g;
// Known workspace scopes (to distinguish from npm packages like @sveltejs/kit)
const WORKSPACE_SCOPES = new Set([
'@mana',
'@calendar',
'@chat',
'@clock',
'@contacts',
'@context',
'@music',
'@nutriphi',
'@photos',
'@picture',
'@planta',
'@presi',
'@storage',
'@todo',
'@traces',
'@zitare',
'@mana-core',
]);
/**
* Build a set of all workspace package names by scanning the monorepo.
*/
function buildWorkspacePackageSet() {
const names = new Set();
const patterns = [
'packages/*/package.json',
'apps/*/packages/*/package.json',
'services/*/package.json',
];
for (const pattern of patterns) {
const parts = pattern.split('/');
let dirs = [MONOREPO_ROOT];
for (const part of parts) {
const nextDirs = [];
for (const dir of dirs) {
if (part === '*') {
try {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
try {
if (statSync(full).isDirectory()) {
nextDirs.push(full);
}
} catch {}
}
} catch {}
} else {
nextDirs.push(join(dir, part));
}
}
dirs = nextDirs;
}
for (const pkgPath of dirs) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.name) names.add(pkg.name);
} catch {}
}
}
return names;
}
/**
* Recursively collect source files from a directory.
*/
function collectSourceFiles(dir, files = []) {
let entries;
try {
entries = readdirSync(dir);
} catch {
return files;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isDirectory()) {
collectSourceFiles(full, files);
} else if (stat.isFile()) {
const ext = entry.slice(entry.lastIndexOf('.'));
if (SCAN_EXTENSIONS.has(ext)) {
files.push(full);
}
}
} catch {}
}
return files;
}
/**
* Extract workspace package imports from a source file.
*/
function extractImports(filePath, workspacePackages) {
const content = readFileSync(filePath, 'utf-8');
const imports = new Set();
let match;
// Reset regex lastIndex
IMPORT_REGEX.lastIndex = 0;
while ((match = IMPORT_REGEX.exec(content)) !== null) {
const pkg = match[1]; // e.g. @mana/shared-utils
const scope = pkg.split('/')[0]; // e.g. @mana
if (WORKSPACE_SCOPES.has(scope) && workspacePackages.has(pkg)) {
imports.add(pkg);
}
}
return imports;
}
/**
* Get declared dependencies from package.json (both deps and devDeps).
*/
function getDeclaredDeps(pkgJsonPath) {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
const deps = new Set();
for (const name of Object.keys(pkg.dependencies || {})) {
deps.add(name);
}
for (const name of Object.keys(pkg.devDependencies || {})) {
deps.add(name);
}
return { deps, pkg };
}
/**
* Find all SvelteKit web app directories.
*/
function findWebApps() {
const appsDir = join(MONOREPO_ROOT, 'apps');
const webApps = [];
try {
const projects = readdirSync(appsDir);
for (const project of projects) {
// Try the correct path structure: apps/{project}/apps/web
const correctWebDir = join(appsDir, project, 'apps', 'web');
if (existsSync(join(correctWebDir, 'package.json'))) {
webApps.push(correctWebDir);
}
}
} catch {}
return webApps.sort();
}
// --- Main ---
console.log(`${BOLD}Workspace Dependency Audit${RESET}`);
console.log(`Scanning SvelteKit web apps for undeclared workspace imports...\n`);
const workspacePackages = buildWorkspacePackageSet();
console.log(`${DIM}Found ${workspacePackages.size} workspace packages${RESET}\n`);
const webApps = findWebApps();
let totalMissing = 0;
const fixes = []; // { webDir, filterName, missingPkgs }
for (const webDir of webApps) {
const relPath = relative(MONOREPO_ROOT, webDir);
const pkgJsonPath = join(webDir, 'package.json');
const srcDir = join(webDir, 'src');
if (!existsSync(srcDir)) {
console.log(`${DIM}- ${relPath} - no src/ directory, skipping${RESET}`);
continue;
}
const { deps, pkg } = getDeclaredDeps(pkgJsonPath);
const sourceFiles = collectSourceFiles(srcDir);
// Map: packageName -> Set of files that import it
const importedPackages = new Map();
for (const file of sourceFiles) {
const fileImports = extractImports(file, workspacePackages);
for (const imp of fileImports) {
if (!importedPackages.has(imp)) {
importedPackages.set(imp, new Set());
}
importedPackages.get(imp).add(relative(webDir, file));
}
}
// Find missing: imported but not declared
const missing = new Map();
for (const [pkgName, files] of importedPackages) {
if (!deps.has(pkgName)) {
missing.set(pkgName, files);
}
}
if (missing.size === 0) {
console.log(`${GREEN}\u2713${RESET} ${relPath} - all imports covered`);
} else {
totalMissing += missing.size;
const filterName = pkg.name || relPath;
console.log(
`${RED}\u2717${RESET} ${relPath} - ${BOLD}${missing.size}${RESET} missing dep${missing.size > 1 ? 's' : ''}:`
);
const missingPkgs = [];
for (const [pkgName, files] of missing) {
const fileList = [...files].sort().join(', ');
console.log(` ${YELLOW}${pkgName}${RESET} ${DIM}(imported in ${fileList})${RESET}`);
missingPkgs.push(pkgName);
}
console.log(` ${DIM}\u2192 pnpm add ${missingPkgs.join(' ')} --filter ${filterName}${RESET}`);
fixes.push({ webDir, pkgJsonPath, filterName, missingPkgs });
}
}
console.log('');
if (totalMissing === 0) {
console.log(`${GREEN}${BOLD}All workspace imports are properly declared!${RESET}`);
process.exit(0);
}
console.log(
`${RED}${BOLD}Found ${totalMissing} missing dependency declaration${totalMissing > 1 ? 's' : ''} across ${fixes.length} app${fixes.length > 1 ? 's' : ''}.${RESET}`
);
if (!FIX_MODE) {
console.log(`\n${DIM}Run with --fix to automatically add missing dependencies.${RESET}`);
process.exit(1);
}
// --- Fix mode ---
console.log(`\n${BOLD}Applying fixes...${RESET}\n`);
for (const { pkgJsonPath, missingPkgs } of fixes) {
const relPkgPath = relative(MONOREPO_ROOT, pkgJsonPath);
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
if (!pkg.dependencies) {
pkg.dependencies = {};
}
for (const pkgName of missingPkgs) {
pkg.dependencies[pkgName] = 'workspace:*';
console.log(` ${GREEN}+${RESET} Added ${pkgName} to ${relPkgPath}`);
}
// Sort dependencies alphabetically
const sorted = {};
for (const key of Object.keys(pkg.dependencies).sort()) {
sorted[key] = pkg.dependencies[key];
}
pkg.dependencies = sorted;
writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, '\t') + '\n');
}
console.log(`\n${BOLD}Running pnpm install to update lockfile...${RESET}\n`);
try {
execSync('pnpm install', {
cwd: MONOREPO_ROOT,
stdio: 'inherit',
});
console.log(`\n${GREEN}${BOLD}All fixes applied successfully!${RESET}`);
} catch {
console.error(`\n${RED}pnpm install failed. You may need to run it manually.${RESET}`);
process.exit(1);
}