Markdown Editor — Cloud Run Backend Architecture
Date: 2026-01-30
Status: 📋 Proposal
Related: Admin Portal, Firebase Auth, GitHub Integration
Overview
A self-hosted, StackEdit-like Markdown editor integrated into the React admin portal. The backend runs on Cloud Run and enables non-GitHub users to edit documentation via a GitHub App with PR-based workflows.
┌─────────────────────────────────────────────────────────────────────────────┐
│ ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
│ │ React │────▶│ Cloud Run │────▶│ GitHub API │ │
│ │ Admin │ │ (Node.js) │ │ (via GitHub App) │ │
│ │ Portal │◀────│ │◀────│ │ │
│ └──────────────┘ └──────────────────┘ └─────────────────────────┘ │
│ │ │ │
│ │ Firebase ID Token │ Validates token │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Firebase │ │ Firestore │ │
│ │ Auth │ │ (user roles) │ │
│ └──────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Recommendation: Single Service Architecture
Why single service over microservices:
| Factor | Single Service | Multiple Services |
|---|---|---|
| Complexity | ✅ Lower | ❌ Higher |
| Cold starts | ✅ One service to warm | ❌ Multiple cold starts |
| Deployment | ✅ Simpler CI/CD | ❌ Coordinated deploys |
| Cost | ✅ Lower at small scale | ❌ Higher fixed costs |
| Latency | ✅ No inter-service calls | ❌ Network hops |
For a documentation editor with modest traffic, a single well-structured Node.js service is optimal. You can split later if specific bottlenecks emerge.
Service Structure
cloud-run-docs-api/
├── src/
│ ├── index.js # Express app entry
│ ├── middleware/
│ │ ├── auth.js # Firebase token validation
│ │ ├── rbac.js # Role-based access control
│ │ └── rateLimiter.js # Rate limiting
│ ├── routes/
│ │ ├── documents.js # CRUD for markdown files
│ │ ├── branches.js # Branch/PR management
│ │ └── health.js # Health checks
│ ├── services/
│ │ ├── github.js # GitHub App client
│ │ ├── pathValidator.js # Allowlist enforcement
│ │ └── conflictResolver.js # SHA conflict detection
│ └── config/
│ ├── allowedPaths.js # Path allowlist config
│ └── roles.js # Role definitions
├── Dockerfile
├── package.json
└── cloudbuild.yamlAPI Endpoints
Documents API
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
GET | /api/documents | List all docs in /docs | viewer+ |
GET | /api/documents/:path | Get file content + SHA | viewer+ |
POST | /api/documents/:path | Create new file (via PR) | editor+ |
PUT | /api/documents/:path | Update file (via PR) | editor+ |
DELETE | /api/documents/:path | Delete file (via PR) | admin |
Branch/PR API
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
GET | /api/branches | List user's draft branches | editor+ |
POST | /api/branches | Create draft branch | editor+ |
GET | /api/branches/:name/diff | Preview changes | editor+ |
POST | /api/branches/:name/pr | Open PR from branch | editor+ |
DELETE | /api/branches/:name | Abandon draft branch | editor+ |
Health/Admin API
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
GET | /health | Liveness probe | None |
GET | /ready | Readiness probe | None |
GET | /api/config/paths | Get allowed paths | admin |
Request/Response Schemas
GET /api/documents/:path
json
// Response 200
{
"path": "docs/features/lanterns/README.md",
"content": "# Lanterns Feature\n\n...",
"sha": "abc123def456...",
"lastModified": "2026-01-30T10:00:00Z",
"lastModifiedBy": "github-username-or-app"
}PUT /api/documents/:path
json
// Request
{
"content": "# Updated Content\n\n...",
"sha": "abc123def456...", // Required: prevents conflicts
"commitMessage": "Update lanterns docs",
"createPr": true, // true = PR workflow, false = direct commit
"branchName": "docs/update-lanterns" // Optional: reuse existing branch
}
// Response 200 (PR created)
{
"success": true,
"pr": {
"number": 42,
"url": "https://github.com/owner/repo/pull/42",
"branch": "docs/update-lanterns"
}
}
// Response 409 (conflict)
{
"error": "CONFLICT",
"message": "File was modified since you loaded it",
"currentSha": "xyz789...",
"yourSha": "abc123..."
}GitHub App Authentication Flow
Setup (One-time)
Create GitHub App at
github.com/settings/apps/new:- Name:
Lantern Docs Editor - Permissions:
- Contents: Read & Write
- Pull Requests: Read & Write
- Metadata: Read-only
- Install on target repository
- Name:
Store credentials in Secret Manager:
GITHUB_APP_ID=123456 GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----... GITHUB_APP_INSTALLATION_ID=12345678
Runtime Auth Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ GITHUB APP AUTH FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Cloud Run loads App private key from Secret Manager │
│ │ │
│ ▼ │
│ 2. Generate JWT (signed with private key, expires in 10 min) │
│ │ │
│ ▼ │
│ 3. POST /app/installations/:id/access_tokens │
│ Authorization: Bearer <JWT> │
│ │ │
│ ▼ │
│ 4. Receive Installation Access Token (expires in 1 hour) │
│ │ │
│ ▼ │
│ 5. Use token for GitHub API calls │
│ Authorization: Bearer <installation_access_token> │
│ │
│ 6. Cache token, refresh before expiry │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Implementation (Node.js)
javascript
// src/services/github.js
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
class GitHubService {
constructor() {
this.installationId = process.env.GITHUB_APP_INSTALLATION_ID;
this.octokit = null;
this.tokenExpiry = null;
}
async getClient() {
// Return cached client if token still valid (with 5 min buffer)
if (this.octokit && this.tokenExpiry > Date.now() + 300000) {
return this.octokit;
}
const auth = createAppAuth({
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
installationId: this.installationId,
});
const { token, expiresAt } = await auth({ type: 'installation' });
this.tokenExpiry = new Date(expiresAt).getTime();
this.octokit = new Octokit({ auth: token });
return this.octokit;
}
async getFile(owner, repo, path, ref = 'main') {
const client = await this.getClient();
const { data } = await client.repos.getContent({
owner,
repo,
path,
ref,
});
return {
content: Buffer.from(data.content, 'base64').toString('utf-8'),
sha: data.sha,
path: data.path,
};
}
async createOrUpdateFile(owner, repo, path, content, sha, message, branch) {
const client = await this.getClient();
return client.repos.createOrUpdateFileContents({
owner,
repo,
path,
message,
content: Buffer.from(content).toString('base64'),
sha, // Required for updates, prevents conflicts
branch,
});
}
async createPullRequest(owner, repo, head, base, title, body) {
const client = await this.getClient();
return client.pulls.create({
owner,
repo,
title,
body,
head,
base,
});
}
}
export default new GitHubService();Security Implementation
1. Firebase Auth Middleware
javascript
// src/middleware/auth.js
import admin from 'firebase-admin';
// Initialize once at startup
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});
export async function verifyFirebaseToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
const idToken = authHeader.split('Bearer ')[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
req.user = {
uid: decodedToken.uid,
email: decodedToken.email,
};
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}2. Role-Based Access Control
javascript
// src/middleware/rbac.js
import admin from 'firebase-admin';
const ROLE_HIERARCHY = {
admin: 3,
editor: 2,
viewer: 1,
};
export function requireRole(minRole) {
return async (req, res, next) => {
const { uid } = req.user;
// Fetch role from Firestore
const userDoc = await admin.firestore()
.collection('users')
.doc(uid)
.get();
const userRole = userDoc.data()?.role || 'viewer';
if (ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[minRole]) {
return res.status(403).json({
error: 'Insufficient permissions',
required: minRole,
current: userRole,
});
}
req.user.role = userRole;
next();
};
}3. Path Allowlist Enforcement
javascript
// src/services/pathValidator.js
const ALLOWED_PATHS = [
/^docs\//, // All files under /docs
/^docs\/.*\.md$/, // Only .md files
];
const DENIED_PATHS = [
/^docs\/engineering\/secrets/, // No secrets folder
/\.env/, // No env files
/node_modules/, // No node_modules
];
export function validatePath(path) {
// Normalize path
const normalizedPath = path.replace(/^\/+/, '').replace(/\/+/g, '/');
// Check for path traversal attacks
if (normalizedPath.includes('..')) {
return { valid: false, reason: 'Path traversal not allowed' };
}
// Check denied paths first
for (const pattern of DENIED_PATHS) {
if (pattern.test(normalizedPath)) {
return { valid: false, reason: 'Path is in deny list' };
}
}
// Check allowed paths
const isAllowed = ALLOWED_PATHS.some(pattern => pattern.test(normalizedPath));
if (!isAllowed) {
return { valid: false, reason: 'Path not in allowlist' };
}
return { valid: true, normalizedPath };
}4. SHA Conflict Detection
javascript
// src/services/conflictResolver.js
export async function checkConflict(github, owner, repo, path, providedSha, branch = 'main') {
try {
const currentFile = await github.getFile(owner, repo, path, branch);
if (currentFile.sha !== providedSha) {
return {
hasConflict: true,
currentSha: currentFile.sha,
currentContent: currentFile.content,
};
}
return { hasConflict: false };
} catch (error) {
if (error.status === 404) {
// File doesn't exist, no conflict for new files
return { hasConflict: false, isNew: true };
}
throw error;
}
}Scaling & Performance
Cloud Run Configuration
yaml
# cloudbuild.yaml or service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: docs-editor-api
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/minScale: "0" # Scale to zero when idle
autoscaling.knative.dev/maxScale: "10" # Max instances
run.googleapis.com/cpu-throttling: "false" # Keep CPU during startup
spec:
containerConcurrency: 80 # Requests per instance
timeoutSeconds: 60
containers:
- image: gcr.io/PROJECT/docs-editor-api
resources:
limits:
cpu: "1"
memory: "512Mi"
env:
- name: NODE_ENV
value: "production"Performance Optimizations
| Optimization | Implementation |
|---|---|
| Token caching | Cache GitHub installation tokens (1hr TTL) |
| Connection pooling | Reuse HTTP agents for GitHub API |
| Response caching | Cache file listings with short TTL (30s) |
| Lazy auth | Don't fetch user role until needed |
| Compression | Enable gzip for large file responses |
Rate Limiting
javascript
// src/middleware/rateLimiter.js
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Use Cloud Memorystore for Redis in production
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
// For Cloud Run with multiple instances:
// store: new RedisStore({ ... }),
});
export const writeLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 writes per minute
message: { error: 'Write rate limit exceeded' },
});Recommended Tech Stack
| Layer | Technology | Reason |
|---|---|---|
| Runtime | Node.js 20 LTS | Fast cold starts, good for I/O |
| Framework | Express.js | Simple, well-documented |
| GitHub SDK | @octokit/rest + @octokit/auth-app | Official, well-maintained |
| Firebase | firebase-admin | Server-side auth verification |
| Validation | zod | Type-safe request validation |
| Logging | pino | Fast structured logging |
| Testing | vitest | Fast, compatible with your frontend |
Deployment Checklist
1. GitHub App Setup
- [ ] Create GitHub App with required permissions
- [ ] Generate and download private key
- [ ] Install App on target repository
- [ ] Note: App ID, Installation ID
2. Secret Manager
- [ ] Store
GITHUB_APP_ID - [ ] Store
GITHUB_APP_PRIVATE_KEY - [ ] Store
GITHUB_APP_INSTALLATION_ID - [ ] Store
GITHUB_OWNERandGITHUB_REPO
3. Cloud Run Service
- [ ] Create service with appropriate memory/CPU
- [ ] Grant Secret Manager access to service account
- [ ] Configure Firebase Admin SDK (auto via ADC)
- [ ] Set up custom domain (optional)
4. Firestore Setup
- [ ] Ensure
userscollection hasrolefield - [ ] Add security rules for role management
5. Frontend Integration
- [ ] Add editor route to admin portal
- [ ] Implement Firebase ID token forwarding
- [ ] Handle 409 conflict responses in UI
Cost Estimate (Monthly)
| Resource | Usage Assumption | Est. Cost |
|---|---|---|
| Cloud Run | 10K requests, 1 vCPU | ~$5 |
| Secret Manager | 6 secrets, 10K accesses | ~$0.50 |
| Cloud Memorystore (optional) | Basic tier | ~$30 |
| GitHub API | Free for App auth | $0 |
| Total | ~$5-35 |
Alternative Considerations
Why Not Other Runtimes?
| Runtime | Pros | Cons |
|---|---|---|
| Node.js ✅ | Fast cold starts, familiar, good Octokit | — |
| Python | Good for data processing | Slower cold starts |
| Go | Fastest runtime | Steeper learning curve |
| Deno | Modern, secure by default | Smaller ecosystem |
Verdict: Node.js is the right choice for this use case.
Why Not Cloud Functions?
Cloud Run is preferred because:
- Longer request timeout (up to 60 min vs 9 min)
- More control over container environment
- Better for streaming responses (live editing)
- Easier to add WebSocket support later
Future Enhancements
- Real-time collaboration — Add WebSocket support for live editing
- Branch per session — Auto-create branches for draft edits
- Merge conflict UI — Three-way diff viewer in admin portal
- Webhook receiver — Sync external changes back to editor
- Image uploads — Support for images via GitHub LFS or Cloud Storage