Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
**/node_modules
.git
dist
build
coverage
*.log
.DS_Store
Thumbs.db
.env
.env.*
!.env.example
43 changes: 43 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ── Server ──────────────────────────────────────────────────────────────
NODE_ENV=development
PORT=5000

# ── Database ────────────────────────────────────────────────────────────
# MongoDB connection string (Atlas or local).
MONGODB_URI=mongodb://localhost:27017/specter

# ── Auth ────────────────────────────────────────────────────────────────
# Secret used to sign JWTs. Use a long, random value in production.
JWT_SECRET=32de172f98c9aecfc18e797f496565763fbf1eed29cd701c31937101cea68981


# ── URLs ────────────────────────────────────────────────────────────────
# Used to build Stripe redirect/return URLs and CORS origin.
CLIENT_URL=http://localhost:5173
SERVER_URL=http://localhost:5000
FRONTEND_URL=http://localhost:5173

# ── Wire API (existing intelligence provider) ──────────────────────────
WIRE_API_KEY=ask_01998127762224e740966ba700dd379ea7c003fbe37d072fc98d129fc96d9392

# ── AI (optional — falls back to rule-based analysis if unset) ────────
ANTHROPIC_API_KEY=

# ── Stripe ──────────────────────────────────────────────────────────────
# Secret key from the Stripe Dashboard (Developers → API keys). Required
# for checkout/portal/cancel/resume to work — without it, billing routes
# return 503 and the app behaves as free-tier-only.
STRIPE_SECRET_KEY=

# Publishable key — not used by the backend directly, but kept here so
# both frontend and backend can be configured from one place if you later
# add client-side Stripe.js elements.
STRIPE_PUBLISHABLE_KEY=

# Signing secret for verifying webhook payloads (Developers → Webhooks →
# your endpoint → "Signing secret"). Required for /api/billing/webhook.
STRIPE_WEBHOOK_SECRET=

# Price ID for the Specter Pro plan ($1.99/month), created in the Stripe
# Dashboard under Products. Required for checkout to work.
STRIPE_PRICE_ID=
23 changes: 23 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# --- Specter backend: production image ---
FROM node:20-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package*.json ./
RUN npm ci --omit=dev

FROM base AS runner
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Runs as a non-root user inside the container
RUN addgroup -g 1001 -S nodejs && adduser -S specter -u 1001
USER specter

EXPOSE 5000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:5000/api/health/live', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"

CMD ["node", "src/index.js"]
69 changes: 68 additions & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
"seed": "node src/scripts/seed.js"
},
"dependencies": {
"express": "^4.18.2",
"@google/generative-ai": "^0.24.1",
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"mongoose": "^8.0.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"axios": "^1.6.0",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"@google/generative-ai": "^0.24.1"
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.0",
"stripe": "^22.3.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
Expand All @@ -33,4 +35,4 @@
],
"author": "Specter Team",
"license": "MIT"
}
}
5 changes: 5 additions & 0 deletions backend/src/config/validateEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export function validateEnvironment() {
console.warn('');
}

if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET || !process.env.STRIPE_PRICE_ID) {
console.warn(' Stripe is not fully configured — billing/checkout routes will return 503.');
console.warn(' Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_ID to enable Specter Pro.\n');
}

console.log('✓ Environment validated');
console.log(` Wire base: ${process.env.WIRE_API_BASE || 'https://api.anakin.io/v1 (default)'}`);
console.log(` Key prefix: ${process.env.WIRE_API_KEY?.slice(0, 15)}...\n`);
Expand Down
59 changes: 59 additions & 0 deletions backend/src/middleware/premium.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Subscription-gating middleware. Routes should use these instead of
* hand-checking `req.user.subscriptionTier` inline — keeps the free/pro
* distinction in one place.
*
* All three assume `authMiddleware` has already run and set `req.userId`.
*/
import { User } from '../models/index.js';
import { isPro, hasCreditsRemaining } from '../services/creditsService.js';

async function loadUser(req, res) {
const user = await User.findById(req.userId);
if (!user) {
res.status(404).json({ error: 'User not found' });
return null;
}
req.currentUser = user; // cache for the route handler, avoids a second lookup
return user;
}

/** Blocks the request unless the user has an active Pro subscription. */
export async function requirePremium(req, res, next) {
const user = await loadUser(req, res);
if (!user) return;

if (!isPro(user)) {
return res.status(402).json({
error: 'This feature requires Specter Pro.',
code: 'PREMIUM_REQUIRED'
});
}
next();
}

/** Blocks the request if a free user has exhausted their investigation credits. Pro users always pass. */
export async function requireCredits(req, res, next) {
const user = await loadUser(req, res);
if (!user) return;

if (!hasCreditsRemaining(user)) {
return res.status(402).json({
error: 'You have no investigation credits remaining. Upgrade to Specter Pro for unlimited investigations.',
code: 'CREDITS_EXHAUSTED',
creditsRemaining: 0
});
}
next();
}

/** Blocks the request unless the user has any recognized subscription record (free counts). Mostly a sanity guard for billing-only routes. */
export async function requireSubscription(req, res, next) {
const user = await loadUser(req, res);
if (!user) return;

if (!user.subscriptionStatus) {
return res.status(403).json({ error: 'No subscription record found for this account.' });
}
next();
}
70 changes: 69 additions & 1 deletion backend/src/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,57 @@ const userSchema = new mongoose.Schema({
},
investigationLimit: {
type: Number,
default: 10
default: 7
},
investigationsUsed: {
type: Number,
default: 0
},
// --- Stripe / billing ---
stripeCustomerId: {
type: String,
default: null,
index: true
},
stripeSubscriptionId: {
type: String,
default: null,
index: true
},
subscriptionStatus: {
type: String,
enum: ['free', 'active', 'trialing', 'past_due', 'cancelled', 'expired', 'incomplete'],
default: 'free'
},
subscriptionPlan: {
type: String,
enum: ['free', 'specter_pro'],
default: 'free'
},
subscriptionCurrentPeriodEnd: {
type: Date,
default: null
},
cancelAtPeriodEnd: {
type: Boolean,
default: false
},
creditsRemaining: {
type: Number,
default: 7
},
trialCreditsGranted: {
type: Boolean,
default: true
},
lastCreditReset: {
type: Date,
default: Date.now
},
billingEmail: {
type: String,
default: null
},
apiKey: {
type: String,
unique: true,
Expand Down Expand Up @@ -190,6 +235,12 @@ anakinJobId: String,
type: Boolean,
default: false
},
// Guards against double-deducting credits if completion processing ever
// runs more than once for the same investigation (retry, webhook replay, etc).
creditConsumed: {
type: Boolean,
default: false
},
tags: [String],
createdAt: {
type: Date,
Expand Down Expand Up @@ -292,6 +343,11 @@ const activityLogSchema = new mongoose.Schema({
}
}, { timestamps: false });

// Matches the exact query pattern used by /analytics/timeline and the
// History page (find by userId, sort by timestamp desc) — more efficient
// than relying on the two separate single-field indexes above for that query.
activityLogSchema.index({ userId: 1, timestamp: -1 });

// SAVED ENTITY MODEL

const savedEntitySchema = new mongoose.Schema({
Expand Down Expand Up @@ -338,6 +394,18 @@ savedEntitySchema.index({ userId: 1, entityValue: 1 }, { unique: true });

// CREATE MODELS

// WEBHOOK EVENT MODEL (Stripe idempotency)
// Stripe can and does redeliver webhooks (retries, manual replay from the
// dashboard). We record each processed event ID so handlers never apply the
// same event twice.
const webhookEventSchema = new mongoose.Schema({
stripeEventId: { type: String, required: true, unique: true, index: true },
type: { type: String, required: true },
processedAt: { type: Date, default: Date.now }
});

export const WebhookEvent = mongoose.model('WebhookEvent', webhookEventSchema);

export const User = mongoose.model('User', userSchema);
export const Investigation = mongoose.model('Investigation', investigationSchema);
export const ThreatReport = mongoose.model('ThreatReport', threatReportSchema);
Expand Down
Loading