stackbilder.com / sample-scaffold

Sample output

What a Governed Scaffold Looks Like

A real Stackbilder scaffold for a multi-tenant SaaS API with Stripe billing, Cloudflare Workers, D1, and KV. This is what every scaffold includes — free tier.

threat-model.md adr-001.md adr-002.md test-plan.md constraints.yaml wrangler.toml

Project structure

The governed scaffold file tree

Every scaffold ships with the project structure and the .ai/ governance directory in the same ZIP. The application files give you the correct layout; the governance files give your AI agents the rules.

my-saas-api/ — scaffold output
my-saas-api/
├── src/
│   ├── worker.ts                    // edge entry point, request router
│   ├── middleware/
│   │   ├── auth.ts                  // session validation, cookie parsing
│   │   └── ratelimit.ts             // KV-backed sliding window rate limiter
│   ├── routes/
│   │   ├── api/
│   │   │   ├── users.ts             // CRUD — tenant-scoped, RLS enforced
│   │   │   ├── billing.ts           // Stripe checkout + portal redirects
│   │   │   └── webhooks.ts          // Stripe webhook receiver (sig-verified)
│   │   └── health.ts                // liveness probe, returns build hash
│   ├── lib/
│   │   ├── db.ts                    // D1 query helpers, prepared statements
│   │   ├── auth.ts                  // session create/destroy, tenant binding
│   │   └── stripe.ts                // Stripe client init, webhook verify
│   └── types.ts                     // Env, Session, Tenant, User interfaces
├── migrations/
│   └── 0001_create_schema.sql       // D1 schema: tenants, users, sessions
├── .ai/                               ← governance directory
│   ├── threat-model.md              // STRIDE analysis, 8 identified threats
│   ├── adr-001-auth-strategy.md     // cookie sessions vs JWTs — decided
│   ├── adr-002-data-isolation.md    // RLS via tenant_id column — decided
│   ├── test-plan.md                 // 23 test specs, 85% coverage target
│   └── constraints.yaml             // machine-readable guardrails for agents
├── wrangler.toml                    // D1, KV, secrets, triggers, routes
├── package.json
└── tsconfig.json
generated in ~18ms · stack cloudflare/workers + d1 + kv · tier free

.ai/threat-model.md

STRIDE threat model — excerpt

Four of the eight identified threats for this architecture. Each threat has an ID, STRIDE category, severity rating, attack vector, and a concrete mitigation mapped to a specific file or configuration. This is not generic security advice — it is analysis specific to the multi-tenant SaaS + Stripe + D1 + KV combination.

.ai/threat-model.md
8 threats identified · 4 shown
# Threat Model — my-saas-api
## Scope: multi-tenant SaaS API · Cloudflare Workers · D1 · KV · Stripe
Architecture surface: public HTTPS edge, D1 tenant data store, KV session + rate-limit store,
Stripe webhook ingest, no origin server (worker-only). Trust boundary: authenticated tenant session.
T-001 SPOOFING CRITICAL
src/lib/auth.ts
JWT algorithm confusion — alg:none bypass
description: If a JWT verification library is instantiated without an explicit algorithm allowlist, an attacker can forge a token with alg:none — the library skips signature verification entirely, accepting any payload as valid. This grants full tenant impersonation.
attack_vector: Attacker crafts a JWT with {"alg":"none"} and an arbitrary {"sub":"target-tenant-id"}. Submits to any protected route. Library accepts without signature check.
cvss_estimate: 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
mitigation: Pin to HS256 or RS256 explicitly at auth initialization. Reject tokens with non-allowlisted alg header. Applied in src/lib/auth.ts:verifyToken() with algorithm: ["HS256"] constraint.
T-003 ELEVATION OF PRIVILEGE CRITICAL
src/routes/api/users.ts
Cross-tenant data access via missing tenant_id predicate
description: In a multi-tenant D1 database, every query that reads or writes user data must include a WHERE tenant_id = ? predicate bound to the authenticated session. A single omitted predicate allows any authenticated tenant to read or modify another tenant's rows.
attack_vector: Authenticated user in tenant A calls GET /api/users/{<uuid>} where the UUID belongs to tenant B. If the query does not filter by tenant_id, the row is returned. Particularly dangerous for IDOR on sequential or predictable IDs.
cvss_estimate: 8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)
mitigation: All D1 queries in src/lib/db.ts use withTenant() helper that appends AND tenant_id = ?1 and binds ctx.tenantId. Migration 0001 adds a composite index on (tenant_id, id). Reviewed in ADR-002.
T-005 INFORMATION DISCLOSURE HIGH
src/worker.ts
D1 error leakage — SQLite schema exposure via verbose errors
description: Cloudflare D1 surfaces SQLite error messages including table names, column names, and constraint names in thrown Error objects. Without sanitization, these propagate to JSON API responses and expose the full schema to any caller who triggers a constraint violation or malformed query.
attack_vector: Send a request body with a duplicate unique field (e.g., email). Observe that the 409 response includes "UNIQUE constraint failed: users.email" — confirming the table and column name. Repeat for each field to map the full schema.
cvss_estimate: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)
mitigation: Global error handler in src/worker.ts maps D1DatabaseError to generic {"error":"internal_error"} with request_id. Structured error codes (duplicate_email, not_found) communicated via HTTP status without leaking implementation details.
T-007 TAMPERING HIGH
src/routes/api/webhooks.ts
Stripe webhook replay without signature verification
description: A Stripe webhook endpoint that parses the event body without verifying the Stripe-Signature header allows any attacker to forge billing events — triggering tier upgrades, cancellation reversals, or invoice.paid events — without actually paying Stripe.
attack_vector: POST to /api/webhooks with a crafted {"type":"customer.subscription.updated","data":{"object":{"status":"active","plan":{"id":"pro"}}}}} body. No Stripe account required if signature check is absent.
cvss_estimate: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N)
mitigation: src/lib/stripe.ts:verifyWebhookSignature() calls Stripe.webhooks.constructEvent() with STRIPE_WEBHOOK_SECRET. Verification happens before any body parsing. Handler returns 400 on signature mismatch without logging the raw payload.

.ai/adr-001-auth-strategy.md

ADR-001: Authentication strategy

Architectural decision records document the non-obvious choices in your system — with the alternatives considered, the constraints that ruled them out, and the consequences your team accepts. When an AI agent or new developer tries to change this decision, the ADR explains exactly why it was made.

.ai/adr-001-auth-strategy.md
# ADR-001: Authentication Strategy
Date: generated · Status: Accepted · Deciders: architecture engine
## Status
ACCEPTED
## Context
This system is a multi-tenant SaaS API running exclusively on Cloudflare Workers with no origin server. Auth state must be validated on every request at the edge. Options are: (1) stateless JWTs validated cryptographically on every request, or (2) session tokens looked up in a backing store (D1 or KV) on every request. The application requires per-session revocation — a compromised session token must be invalidatable without waiting for token expiry.
## Decision
Use opaque session tokens stored in HttpOnly + Secure cookies, backed by a KV namespace (SESSION) as the session store. Session records contain tenant_id, user_id, created_at, last_seen_at, and expires_at. Token format: 32 bytes of cryptographically random data, hex-encoded. Session lookup adds one KV read per request (~1ms at Cloudflare edge) — acceptable for this latency budget.
## Consequences
Positive
  • + Immediate session revocation on logout or compromise
  • + No token refresh loop — session stays valid until explicit expiry
  • + Cannot be tampered with — opaque token carries no claims
  • + HttpOnly cookie is inaccessible to XSS payloads
  • + Session store provides audit trail of active sessions per tenant
Trade-offs accepted
  • - One KV read per authenticated request (~1ms latency overhead)
  • - Session store is a dependency — KV outage degrades auth
  • - CSRF protection required (SameSite=Strict mitigates for same-origin)
  • - Not suitable for native mobile apps needing Authorization header
## Alternatives Considered
Stateless JWTs (rejected): Cryptographically self-contained, zero backing-store lookup. Rejected because revocation requires a denylist (which adds KV lookup anyway) or waiting for token expiry. A compromised JWT with a 15-minute TTL remains valid for up to 15 minutes — unacceptable for a multi-tenant system where a single session may have access to billing and user PII.
D1 session store (rejected): Would place auth reads in the same database as tenant data, coupling auth availability to D1 availability. KV is a faster, purpose-built key-value store and is the appropriate choice for a session lookup hot path.
Third-party auth provider (deferred): Viable for a later stage. Current scope does not justify the operational complexity or vendor dependency. The session model is compatible with a future migration to an external auth service.

.ai/test-plan.md

Integration test plan — excerpt

Not boilerplate test categories — specific test cases derived from the threat model and architectural decisions. The test plan is structured so you can hand it directly to Vitest or a QA engineer without translation.

.ai/test-plan.md
23 test specs · 85% coverage target · vitest
Framework: vitest + @cloudflare/vitest-pool-workers   Coverage: 85% (statements)   Priority: authbillingisolationratelimit
Authentication & Session Tests 7 specs
AUTH-001
login returns session cookie on valid credentials
POST /api/auth/login with correct password → 200, Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict
AUTH-002
protected route rejects missing session cookie
GET /api/users without cookie → 401 {"error":"unauthenticated"}
AUTH-003
logout invalidates session in KV store
POST /api/auth/logout → KV.delete(sessionToken). Subsequent GET /api/users with old cookie → 401
AUTH-004
expired session cookie returns 401 not 500
Mock KV to return null for session lookup. Verify 401 response, not unhandled rejection.
AUTH-005
rejects JWT with alg:none
Craft Authorization: Bearer with alg:none payload. Verify 401 — does not reach route handler.
Billing & Webhook Tests 5 specs
BILL-001
webhook rejects missing Stripe-Signature header
POST /api/webhooks without Stripe-Signature → 400. Verify no DB state change.
BILL-002
webhook rejects invalid HMAC signature
POST /api/webhooks with malformed signature → 400. Stripe.webhooks.constructEvent throws, caught, returns 400.
BILL-003
invoice.paid upgrades tenant tier in D1
POST valid signed invoice.paid event → tenant row updated: plan='pro', current_period_end=<epoch>. Returns 200.
BILL-004
duplicate webhook event is idempotent
POST same event twice with same Stripe event ID → second invocation returns 200, no duplicate D1 write (INSERT OR IGNORE on event_id).
BILL-005
customer.subscription.deleted downgrades tenant
POST valid signed customer.subscription.deleted → tenant.plan reset to 'free', subscription_id cleared.
Multi-Tenant Isolation Tests 4 specs
ISO-001
GET /api/users/:id returns 404 for cross-tenant ID
Seed user UUID in tenant A. Authenticate as tenant B. GET /api/users/<tenant-A-uuid> → 404 (not 403, not the actual row).
ISO-002
GET /api/users returns only current tenant's rows
Seed 3 users in tenant A, 2 in tenant B. Authenticate as tenant A. GET /api/users → 3 records, none from tenant B.
ISO-003
PATCH /api/users/:id ignores cross-tenant write
Authenticate as tenant A. PATCH /api/users/<tenant-B-uuid> {"role":"admin"} → 404. Verify tenant B user is unmodified.
ISO-004
withTenant() helper always appends tenant_id predicate
Unit test: withTenant(ctx, 'SELECT * FROM users WHERE id = ?', [uuid]) produces SQL with AND tenant_id = ? appended and bindings length is 2.
Rate Limiting Tests 3 specs
RATE-001
login endpoint allows 5 attempts per minute per IP
POST /api/auth/login 5 times → 200 (even if wrong password). 6th attempt within 60s → 429 {"error":"rate_limited","retry_after":42}.
RATE-002
rate limit resets after window expires
Exhaust limit (5 attempts). Advance mock clock 61 seconds. POST /api/auth/login → not 429.
RATE-003
rate limit state is per-IP not per-tenant
Exhaust limit from IP-A. Verify IP-B can still login. KV keys inspected: rate:<ip> not rate:<tenant>.

wrangler.toml

Bindings, wired correctly

D1 database, KV namespace, Stripe webhook secret, and Durable Objects configured for the correct environment splits. No hand-editing required.

wrangler.toml
name = "my-saas-api"
main = "src/worker.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]

# D1 — tenant data + sessions (separate DBs)
[[d1_databases]]
binding = "DB"
database_name = "my-saas-api-prod"
database_id = "<replace-after-create>"

# KV — session store + rate-limit counters
[[kv_namespaces]]
binding = "SESSION"
id = "<replace-after-create>"

# Secrets — never in source
# wrangler secret put STRIPE_SECRET_KEY
# wrangler secret put STRIPE_WEBHOOK_SECRET
# wrangler secret put SESSION_SECRET

[env.staging]
name = "my-saas-api-staging"
[[env.staging.d1_databases]]
binding = "DB"
database_name = "my-saas-api-staging"
database_id = "<replace-after-create>"

[triggers]
crons = ["0 * * * *"] # hourly: session cleanup

.ai/constraints.yaml

Agent guardrails

Machine-readable constraints your AI coding agents read before modifying the codebase. Prevents architectural drift — the next Cursor session cannot accidentally disable auth or add a direct DB client without triggering a violation.

.ai/constraints.yaml
version: 1
scaffold_id: "my-saas-api"
stack: "cloudflare-workers"

invariants:
  # Rules that must never be violated
  - id: auth-middleware-required
    description: "All routes under /api/* must pass
      through src/middleware/auth.ts before
      reaching route handlers."
    severity: critical

  - id: tenant-predicate-required
    description: "Every D1 query touching tenant
      data must use withTenant() helper.
      Direct DB.prepare() on tenant tables
      is forbidden."
    severity: critical

  - id: webhook-signature-required
    description: "Stripe webhook handler must call
      verifyWebhookSignature() before any
      body parsing or DB mutation."
    severity: critical

  - id: no-secrets-in-source
    description: "API keys, webhook secrets, and
      session secrets must be accessed via
      env.BINDING_NAME only. Hardcoded
      credential strings are forbidden."
    severity: critical

decisions:
  - adr-001  # cookie sessions — do not add JWT
  - adr-002  # RLS via withTenant() — no row policies

threat_model_ref: ".ai/threat-model.md"

Questions about scaffold output

Is this real scaffold output or a mock?

This is a realistic representation of what Stackbilder generates for a multi-tenant SaaS API with Stripe billing on Cloudflare Workers. The threat model threats, ADR structure, test specs, and constraint YAML are generated by the same deterministic engine that runs on every scaffold — same input, same output.

How do I download my scaffold files?

After generating a scaffold, the flow detail page gives you a ZIP download containing the full project structure and .ai/ governance directory. Individual files can also be copied to clipboard. The ZIP is production-ready: drop it in a new directory and wrangler deploy works.

Does it work for stacks other than Cloudflare Workers?

Cloudflare Workers — with D1, KV, R2, and service bindings — is fully supported today. The governance artifacts (threat model, ADRs, test plan, constraints) are substantially stack-agnostic and useful regardless of what you're building on. Additional stacks are on the roadmap.

What's different in Pro?

Free tier includes the full governance suite — threat model, ADRs, test plan, project scaffold — for up to 3 scaffolds per month. Pro ($29/mo) removes the monthly limit and adds LLM polish mode: idiomatic implementation code for worker.ts, route handlers, middleware, and migration SQL, guided by the deterministic governance constraints.

Generate your own governed scaffold

Your architecture. Your threat model. Your ADRs.

Describe your app. Get the full governance suite in under 20ms. Free tier includes 3 scaffolds per month — no credit card.

Threat model 2 ADRs 23-spec test plan wrangler.toml constraints.yaml ~20ms generation

Related pages