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