scooling-note-outline-smoke.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | import express from 'express'; |
| 2 | |
| 3 | const ALLOWED_ENVIRONMENTS = new Set(['local', 'staging']); |
| 4 | const FORBIDDEN_REQUEST_HEADERS = ['authorization', 'cookie', 'x-api-key']; |
| 5 | |
| 6 | function normalizeBooleanFlag(value) { |
| 7 | return value === true || value === '1' || value === 'true'; |
| 8 | } |
| 9 | |
| 10 | function configuredEnvironment() { |
| 11 | return String( |
| 12 | process.env.SCOOLING_NOTE_OUTLINE_SMOKE_ENV || |
| 13 | process.env.KNOWTATION_ENV || |
| 14 | process.env.HUB_ENV || |
| 15 | '', |
| 16 | ) |
| 17 | .trim() |
| 18 | .toLowerCase(); |
| 19 | } |
| 20 | |
| 21 | function smokeEnabled() { |
| 22 | return normalizeBooleanFlag(process.env.SCOOLING_NOTE_OUTLINE_SMOKE_ENABLED); |
| 23 | } |
| 24 | |
| 25 | function defaultAuthorizationHeader() { |
| 26 | const token = String(process.env.SCOOLING_NOTE_OUTLINE_SMOKE_BEARER_TOKEN || '').trim(); |
| 27 | return token ? `Bearer ${token}` : ''; |
| 28 | } |
| 29 | |
| 30 | function normalizeVaultRelativePath(rawPath) { |
| 31 | if (typeof rawPath !== 'string' || rawPath.trim() === '') { |
| 32 | throw new Error('Invalid path'); |
| 33 | } |
| 34 | const forward = rawPath.trim().replace(/\\/g, '/'); |
| 35 | if (forward.startsWith('/') || /^[A-Za-z]:\//.test(forward)) { |
| 36 | throw new Error('Invalid path'); |
| 37 | } |
| 38 | const parts = forward.split('/').filter(Boolean); |
| 39 | if (parts.includes('..')) { |
| 40 | throw new Error('Invalid path'); |
| 41 | } |
| 42 | return parts.join('/'); |
| 43 | } |
| 44 | |
| 45 | function normalizeEndpoint(rawEndpoint) { |
| 46 | const endpoint = String(rawEndpoint || '').trim(); |
| 47 | if (!endpoint) { |
| 48 | throw new Error('Missing endpoint'); |
| 49 | } |
| 50 | const url = new URL(endpoint); |
| 51 | if (url.protocol !== 'http:' && url.protocol !== 'https:') { |
| 52 | throw new Error('Invalid endpoint'); |
| 53 | } |
| 54 | if (url.username || url.password || url.search || url.hash) { |
| 55 | throw new Error('Invalid endpoint'); |
| 56 | } |
| 57 | return url.toString(); |
| 58 | } |
| 59 | |
| 60 | function hasForbiddenRequestCredentials(req) { |
| 61 | return FORBIDDEN_REQUEST_HEADERS.some((name) => { |
| 62 | const value = req.headers[name]; |
| 63 | return Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.length > 0; |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | function sanitizeError(status, error, code) { |
| 68 | return { |
| 69 | status, |
| 70 | body: { |
| 71 | error, |
| 72 | code, |
| 73 | containsRawCredentials: false, |
| 74 | returnedBodyText: false, |
| 75 | performedWrite: false, |
| 76 | }, |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | function safeString(value) { |
| 81 | return typeof value === 'string' ? value : ''; |
| 82 | } |
| 83 | |
| 84 | function isSafeHeading(value) { |
| 85 | if (!value || typeof value !== 'object' || Array.isArray(value)) return false; |
| 86 | const keys = Object.keys(value); |
| 87 | return ( |
| 88 | keys.length === 3 && |
| 89 | Number.isInteger(value.level) && |
| 90 | value.level >= 1 && |
| 91 | value.level <= 6 && |
| 92 | typeof value.text === 'string' && |
| 93 | typeof value.id === 'string' |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | function sanitizeNoteOutline(payload, requestedPath) { |
| 98 | if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { |
| 99 | throw new Error('Invalid NoteOutline'); |
| 100 | } |
| 101 | if (payload.schema !== 'knowtation.note_outline/v1') { |
| 102 | throw new Error('Invalid NoteOutline'); |
| 103 | } |
| 104 | if (payload.path !== requestedPath) { |
| 105 | throw new Error('Invalid NoteOutline'); |
| 106 | } |
| 107 | if (!Array.isArray(payload.headings) || !payload.headings.every(isSafeHeading)) { |
| 108 | throw new Error('Invalid NoteOutline'); |
| 109 | } |
| 110 | if (typeof payload.truncated !== 'boolean') { |
| 111 | throw new Error('Invalid NoteOutline'); |
| 112 | } |
| 113 | |
| 114 | return { |
| 115 | schema: 'knowtation.note_outline/v1', |
| 116 | path: requestedPath, |
| 117 | title: payload.title === null ? null : safeString(payload.title), |
| 118 | headings: payload.headings.map((heading) => ({ |
| 119 | level: heading.level, |
| 120 | text: heading.text, |
| 121 | id: heading.id, |
| 122 | })), |
| 123 | truncated: payload.truncated, |
| 124 | }; |
| 125 | } |
| 126 | |
| 127 | function mapUpstreamStatus(status) { |
| 128 | if (status === 401 || status === 403) { |
| 129 | return sanitizeError(403, 'Forbidden', 'FORBIDDEN'); |
| 130 | } |
| 131 | if (status === 404) { |
| 132 | return sanitizeError(404, 'Not found', 'NOT_FOUND'); |
| 133 | } |
| 134 | return sanitizeError(502, 'Bad Gateway', 'BAD_GATEWAY'); |
| 135 | } |
| 136 | |
| 137 | async function readBridgeNoteOutline({ endpoint, authorizationHeader, fetchImpl, requestedPath }) { |
| 138 | const upstreamUrl = new URL(endpoint); |
| 139 | upstreamUrl.searchParams.set('path', requestedPath); |
| 140 | |
| 141 | const response = await fetchImpl(upstreamUrl.toString(), { |
| 142 | method: 'GET', |
| 143 | headers: { |
| 144 | Accept: 'application/json', |
| 145 | Authorization: authorizationHeader, |
| 146 | }, |
| 147 | }); |
| 148 | |
| 149 | if (!response.ok) { |
| 150 | throw mapUpstreamStatus(response.status); |
| 151 | } |
| 152 | |
| 153 | let payload; |
| 154 | try { |
| 155 | payload = await response.json(); |
| 156 | } catch (_) { |
| 157 | throw sanitizeError(502, 'Bad Gateway', 'BAD_GATEWAY'); |
| 158 | } |
| 159 | |
| 160 | try { |
| 161 | return sanitizeNoteOutline(payload, requestedPath); |
| 162 | } catch (_) { |
| 163 | throw sanitizeError(502, 'Bad Gateway', 'BAD_GATEWAY'); |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | function createScoolingNoteOutlineSmokeRouter({ |
| 168 | upstreamEndpoint = process.env.SCOOLING_NOTE_OUTLINE_SMOKE_UPSTREAM || '', |
| 169 | authorizationHeader = defaultAuthorizationHeader, |
| 170 | fetchImpl = globalThis.fetch, |
| 171 | isEnabled = smokeEnabled, |
| 172 | environment = configuredEnvironment, |
| 173 | } = {}) { |
| 174 | const router = express.Router(); |
| 175 | |
| 176 | router.get('/scooling/note-outline/smoke', async (req, res) => { |
| 177 | if (!isEnabled() || !ALLOWED_ENVIRONMENTS.has(environment())) { |
| 178 | return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' }); |
| 179 | } |
| 180 | if (hasForbiddenRequestCredentials(req)) { |
| 181 | return res |
| 182 | .status(400) |
| 183 | .json(sanitizeError(400, 'Raw credentials are not accepted.', 'BAD_REQUEST').body); |
| 184 | } |
| 185 | |
| 186 | let requestedPath; |
| 187 | let endpoint; |
| 188 | let authHeader; |
| 189 | try { |
| 190 | requestedPath = normalizeVaultRelativePath(req.query.path); |
| 191 | } catch (_) { |
| 192 | const err = sanitizeError(400, 'Invalid NoteOutline smoke request.', 'BAD_REQUEST'); |
| 193 | return res.status(err.status).json(err.body); |
| 194 | } |
| 195 | |
| 196 | try { |
| 197 | endpoint = normalizeEndpoint(upstreamEndpoint); |
| 198 | authHeader = authorizationHeader(); |
| 199 | if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ') || authHeader.length <= 7) { |
| 200 | throw new Error('Missing authorization'); |
| 201 | } |
| 202 | } catch (_) { |
| 203 | const err = sanitizeError(503, 'NoteOutline smoke bridge is unavailable.', 'SERVICE_UNAVAILABLE'); |
| 204 | return res.status(err.status).json(err.body); |
| 205 | } |
| 206 | |
| 207 | try { |
| 208 | const outline = await readBridgeNoteOutline({ |
| 209 | endpoint, |
| 210 | authorizationHeader: authHeader, |
| 211 | fetchImpl, |
| 212 | requestedPath, |
| 213 | }); |
| 214 | return res.json(outline); |
| 215 | } catch (error) { |
| 216 | const sanitized = error && typeof error === 'object' && error.body ? error : sanitizeError(502, 'Bad Gateway', 'BAD_GATEWAY'); |
| 217 | return res.status(sanitized.status).json(sanitized.body); |
| 218 | } |
| 219 | }); |
| 220 | |
| 221 | return router; |
| 222 | } |
| 223 | |
| 224 | export { |
| 225 | createScoolingNoteOutlineSmokeRouter, |
| 226 | normalizeVaultRelativePath, |
| 227 | sanitizeNoteOutline, |
| 228 | }; |