@fabrika/proxy-core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -0
- package/src/authorize.ts +406 -0
- package/src/cache.ts +0 -0
- package/src/constants.ts +48 -0
- package/src/gates.ts +65 -0
- package/src/handoff.ts +61 -0
- package/src/iam.ts +257 -0
- package/src/index.ts +43 -0
- package/src/json.ts +44 -0
- package/src/log.ts +58 -0
- package/src/service.ts +312 -0
- package/src/verifier.ts +105 -0
package/src/iam.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy's view of the IAM service: the three cold-path calls it needs, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* `IamGateway` is a STRUCTURAL SUBSET of `IamRpc`, so on Cloudflare the thin Worker passes its
|
|
5
|
+
* `env.IAM` service binding straight in with no adapter. On Zerops there is no binding, so
|
|
6
|
+
* `HttpIamGateway` speaks HTTP to the global IAM service — the public hop ADR-0007 calls cold-path
|
|
7
|
+
* only: everything after the first exchange is a local JWKS verification against the cached token.
|
|
8
|
+
*
|
|
9
|
+
* FAIL-CLOSED CONTRACT: an unreachable, slow or incoherent IAM raises `IamUnavailableError`. A
|
|
10
|
+
* *decided* negative (`{ ok: false, reason }`) is returned normally. The caller must map the former
|
|
11
|
+
* to a deny too — the distinction only chooses the status, never the outcome.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
ExchangeAuthCodeInput,
|
|
16
|
+
ExchangeAuthCodeResult,
|
|
17
|
+
Jwks,
|
|
18
|
+
MintFromKeyInput,
|
|
19
|
+
MintFromKeyResult,
|
|
20
|
+
MintTokenInput,
|
|
21
|
+
MintTokenResult,
|
|
22
|
+
PublicJwk,
|
|
23
|
+
} from '@fabrika/auth-core'
|
|
24
|
+
import { DEFAULT_IAM_TIMEOUT_MS } from './constants'
|
|
25
|
+
import { numberField, prop, stringField } from './json'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the proxy needs of IAM. IAM's entrypoints satisfy it structurally — the CF binding drops
|
|
29
|
+
* straight in with no adapter. Three of the four calls are `IamRpc`; `exchangeAuthCode` is
|
|
30
|
+
* deliberately NOT (`IamHandoffRpc`, ADR-0021), because only the proxy may turn a handoff code into
|
|
31
|
+
* a session and `IamRpc` is held by every SDK consumer.
|
|
32
|
+
*/
|
|
33
|
+
export interface IamGateway {
|
|
34
|
+
mintToken(input: MintTokenInput): Promise<MintTokenResult>
|
|
35
|
+
mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult>
|
|
36
|
+
exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult>
|
|
37
|
+
getJwks(): Promise<Jwks>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* IAM could not be consulted — network failure, timeout, non-200, or a body we cannot read. Distinct
|
|
42
|
+
* from a decided denial: we do not know the answer, so the request is denied with 503 rather than
|
|
43
|
+
* being told it is unauthorized.
|
|
44
|
+
*
|
|
45
|
+
* Carries NO cause and no response body: an error object from `fetch` can carry a URL, and a body
|
|
46
|
+
* can carry a token.
|
|
47
|
+
*/
|
|
48
|
+
export class IamUnavailableError extends Error {
|
|
49
|
+
constructor(readonly operation: string) {
|
|
50
|
+
super(`iam unavailable: ${operation}`)
|
|
51
|
+
this.name = 'IamUnavailableError'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HttpIamGatewayOptions {
|
|
56
|
+
/** The IAM service origin, e.g. `https://iam.example.com` (trailing slash tolerated). */
|
|
57
|
+
origin: string
|
|
58
|
+
/** Per-call timeout. A hung IAM must deny, not hang every request behind the proxy. */
|
|
59
|
+
timeoutMs?: number
|
|
60
|
+
/** Optional `Authorization: Bearer` the proxy authenticates ITSELF to IAM with. */
|
|
61
|
+
key?: string
|
|
62
|
+
/** Injectable for tests; defaults to the global `fetch`. */
|
|
63
|
+
fetch?: FetchLike
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Just the call shape the gateway uses. Narrower than `typeof fetch` on purpose: the global carries
|
|
68
|
+
* runtime-specific extras (Bun's `preconnect`) that a test double has no business implementing.
|
|
69
|
+
*/
|
|
70
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* HTTP transport for `IamGateway` — the Zerops path, where there is no service binding.
|
|
74
|
+
*
|
|
75
|
+
* This file and `packages/iam/src/rpc-http.ts` are two halves of one contract; neither may move
|
|
76
|
+
* without the other in the SAME commit. A mismatch fails closed (any non-200 becomes
|
|
77
|
+
* `IamUnavailableError` → a 503 deny) but is very hard to read from the outside.
|
|
78
|
+
*
|
|
79
|
+
* POST {origin}/auth/mint/session {app, session, requestId} -> MintTokenResult
|
|
80
|
+
* POST {origin}/auth/mint/key {app, key, requestId} -> MintFromKeyResult
|
|
81
|
+
* POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0021)
|
|
82
|
+
* GET {origin}/.well-known/jwks.json -> Jwks (public)
|
|
83
|
+
*
|
|
84
|
+
* The three POST endpoints are the `/auth/mint/*` PROXY surface, gated by IAM's `proxyKey` — the
|
|
85
|
+
* least-privilege split ADR-0007 requires, kept apart from the management `/rpc/*` surface. They
|
|
86
|
+
* answer **200 on any decided outcome, including a denial**: `{ ok: false, reason }` is "IAM answered:
|
|
87
|
+
* no", a non-200 is "IAM could not be consulted". Both deny; only the second is an incident.
|
|
88
|
+
*
|
|
89
|
+
* Credentials always ride in the POST body, never in a URL — a URL ends up in access logs and error
|
|
90
|
+
* strings; a body does not.
|
|
91
|
+
*/
|
|
92
|
+
export class HttpIamGateway implements IamGateway {
|
|
93
|
+
private readonly origin: string
|
|
94
|
+
private readonly timeoutMs: number
|
|
95
|
+
private readonly key: string | undefined
|
|
96
|
+
private readonly fetchImpl: FetchLike
|
|
97
|
+
|
|
98
|
+
constructor(options: HttpIamGatewayOptions) {
|
|
99
|
+
this.origin = options.origin.replace(/\/+$/, '')
|
|
100
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_IAM_TIMEOUT_MS
|
|
101
|
+
this.key = options.key
|
|
102
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
|
|
106
|
+
const parsed = parseMintResult(await this.post('/auth/mint/session', input, 'mintToken'))
|
|
107
|
+
if (parsed === null) {
|
|
108
|
+
throw new IamUnavailableError('mintToken')
|
|
109
|
+
}
|
|
110
|
+
if (parsed.ok) {
|
|
111
|
+
return parsed
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, reason: reasonOf(parsed.reason, ['no_session', 'invalid_session', 'unknown_principal', 'disabled'], 'invalid_session') }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
|
|
117
|
+
const parsed = parseMintResult(await this.post('/auth/mint/key', input, 'mintFromKey'))
|
|
118
|
+
if (parsed === null) {
|
|
119
|
+
throw new IamUnavailableError('mintFromKey')
|
|
120
|
+
}
|
|
121
|
+
if (parsed.ok) {
|
|
122
|
+
return parsed
|
|
123
|
+
}
|
|
124
|
+
return { ok: false, reason: reasonOf(parsed.reason, ['invalid_key', 'unknown_principal', 'disabled'], 'invalid_key') }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult> {
|
|
128
|
+
const body = await this.post('/auth/mint/exchange', input, 'exchangeAuthCode')
|
|
129
|
+
const ok = prop(body, 'ok')
|
|
130
|
+
if (ok === true) {
|
|
131
|
+
const session = stringField(body, 'session')
|
|
132
|
+
const returnUrl = stringField(body, 'returnUrl')
|
|
133
|
+
const expiresAt = numberField(body, 'expiresAt')
|
|
134
|
+
if (session === undefined || session === '' || returnUrl === undefined || returnUrl === '' || expiresAt === undefined) {
|
|
135
|
+
throw new IamUnavailableError('exchangeAuthCode')
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, session, returnUrl, expiresAt }
|
|
138
|
+
}
|
|
139
|
+
if (ok !== false) {
|
|
140
|
+
throw new IamUnavailableError('exchangeAuthCode')
|
|
141
|
+
}
|
|
142
|
+
const reason = stringField(body, 'reason')
|
|
143
|
+
if (reason === undefined) {
|
|
144
|
+
throw new IamUnavailableError('exchangeAuthCode')
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: reasonOf(reason, ['invalid_code', 'expired_code', 'wrong_app', 'invalid_verifier', 'unknown_principal', 'disabled'], 'invalid_code'),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async getJwks(): Promise<Jwks> {
|
|
153
|
+
const response = await this.send('/.well-known/jwks.json', undefined, 'getJwks')
|
|
154
|
+
const body: unknown = await response.json().catch(() => null)
|
|
155
|
+
const jwks = parseJwks(body)
|
|
156
|
+
if (jwks === null) {
|
|
157
|
+
throw new IamUnavailableError('getJwks')
|
|
158
|
+
}
|
|
159
|
+
return jwks
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async post(path: string, payload: unknown, operation: string): Promise<unknown> {
|
|
163
|
+
const response = await this.send(path, JSON.stringify(payload), operation)
|
|
164
|
+
return response.json().catch(() => null)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async send(path: string, body: string | undefined, operation: string): Promise<Response> {
|
|
168
|
+
const headers = new Headers({ accept: 'application/json' })
|
|
169
|
+
if (body !== undefined) {
|
|
170
|
+
headers.set('content-type', 'application/json')
|
|
171
|
+
}
|
|
172
|
+
if (this.key !== undefined) {
|
|
173
|
+
headers.set('authorization', `Bearer ${this.key}`)
|
|
174
|
+
}
|
|
175
|
+
let response: Response
|
|
176
|
+
try {
|
|
177
|
+
response = await this.fetchImpl(`${this.origin}${path}`, {
|
|
178
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
179
|
+
headers,
|
|
180
|
+
body,
|
|
181
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
182
|
+
})
|
|
183
|
+
} catch {
|
|
184
|
+
// Deliberately swallow the cause: a fetch error stringifies the request URL and, on some
|
|
185
|
+
// runtimes, request detail. We only ever surface the operation name.
|
|
186
|
+
throw new IamUnavailableError(operation)
|
|
187
|
+
}
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new IamUnavailableError(operation)
|
|
190
|
+
}
|
|
191
|
+
return response
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The shared `{ok:true,token,expiresAt} | {ok:false,reason}` wire shape, read structurally. */
|
|
196
|
+
type ParsedMint = { ok: true; token: string; expiresAt: number } | { ok: false; reason: string }
|
|
197
|
+
|
|
198
|
+
function parseMintResult(body: unknown): ParsedMint | null {
|
|
199
|
+
const ok = prop(body, 'ok')
|
|
200
|
+
if (ok === true) {
|
|
201
|
+
const token = stringField(body, 'token')
|
|
202
|
+
const expiresAt = numberField(body, 'expiresAt')
|
|
203
|
+
if (token === undefined || token === '' || expiresAt === undefined) {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, token, expiresAt }
|
|
207
|
+
}
|
|
208
|
+
if (ok === false) {
|
|
209
|
+
const reason = stringField(body, 'reason')
|
|
210
|
+
return reason === undefined ? null : { ok: false, reason }
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Narrow a wire `reason` string to the declared union without a cast. An unrecognised reason degrades
|
|
217
|
+
* to the given fallback, which is always a DENY reason — an IAM that starts speaking a dialect we do
|
|
218
|
+
* not know must not be able to talk us into an allow.
|
|
219
|
+
*/
|
|
220
|
+
function reasonOf<T extends string>(value: string, allowed: readonly T[], fallback: T): T {
|
|
221
|
+
for (const candidate of allowed) {
|
|
222
|
+
if (candidate === value) {
|
|
223
|
+
return candidate
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return fallback
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read a key set, rejecting one we cannot fully represent. `PublicJwk` carries only the EC members,
|
|
231
|
+
* so a key of any other type would be copied into a shape missing the material that verifies it — a
|
|
232
|
+
* silently short key set, which reads as "this token is invalid" (a client problem) instead of "we
|
|
233
|
+
* could not check" (an incident). IAM signs ES256 only, so anything else means we are not reading the
|
|
234
|
+
* key set we think we are: refuse the whole fetch and let the caller raise `IamUnavailableError`.
|
|
235
|
+
*/
|
|
236
|
+
function parseJwks(body: unknown): Jwks | null {
|
|
237
|
+
const rawKeys = prop(body, 'keys')
|
|
238
|
+
if (!Array.isArray(rawKeys)) {
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
const keys: PublicJwk[] = []
|
|
242
|
+
for (const raw of rawKeys) {
|
|
243
|
+
const kty = stringField(raw, 'kty')
|
|
244
|
+
if (kty !== 'EC') {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
const key: PublicJwk = { kty }
|
|
248
|
+
for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
|
|
249
|
+
const value = stringField(raw, field)
|
|
250
|
+
if (value !== undefined) {
|
|
251
|
+
key[field] = value
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
keys.push(key)
|
|
255
|
+
}
|
|
256
|
+
return { keys }
|
|
257
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @fabrika/proxy-core — the auth enforcement decision, as a library.
|
|
2
|
+
//
|
|
3
|
+
// ADR-0022 makes the proxy the ONLY enforcement point, and there are two of them: a Bun service
|
|
4
|
+
// answering Caddy's `forward_auth` subrequest on Zerops, and the Cloudflare proxy Worker calling the
|
|
5
|
+
// same code in-process. This package is what they share — take a forwarded request, decide, and mint
|
|
6
|
+
// or refuse. It is runtime-neutral by construction: no Caddy, no filesystem, no process.
|
|
7
|
+
//
|
|
8
|
+
// The deployment that fronts it on Zerops — the Caddy configuration and the compiled binary — is
|
|
9
|
+
// `@fabrika/proxy`, which is private because it is an artifact rather than an API. The manifest wire
|
|
10
|
+
// contract and its strict parser are `@fabrika/proxy-contract`; the gate matcher and the token claims
|
|
11
|
+
// are `@fabrika/auth-core`.
|
|
12
|
+
|
|
13
|
+
export { Authorizer } from './authorize'
|
|
14
|
+
export type { AuthorizerOptions, Decision, DenyReason, ForwardedRequest, ResolvedApp } from './authorize'
|
|
15
|
+
export { cacheKey, MemoryTokenCache } from './cache'
|
|
16
|
+
export type { CachedToken, TokenCache } from './cache'
|
|
17
|
+
export {
|
|
18
|
+
APP_QUERY_PARAM,
|
|
19
|
+
CLIENT_ADDRESS_HEADER,
|
|
20
|
+
DEFAULT_HEALTH_PATH,
|
|
21
|
+
DEFAULT_IAM_TIMEOUT_MS,
|
|
22
|
+
DEFAULT_VERIFY_PATH,
|
|
23
|
+
FORWARDED_HOST_HEADER,
|
|
24
|
+
FORWARDED_METHOD_HEADER,
|
|
25
|
+
FORWARDED_PROTO_HEADER,
|
|
26
|
+
FORWARDED_URI_HEADER,
|
|
27
|
+
JWKS_TTL_SECONDS,
|
|
28
|
+
LOGIN_REDIRECT_PARAM,
|
|
29
|
+
PROXY_TOKEN_HEADER,
|
|
30
|
+
REQUEST_ID_HEADER,
|
|
31
|
+
UNTRUSTED_FORWARD_HEADERS,
|
|
32
|
+
} from './constants'
|
|
33
|
+
export { applicableGates, compileGates, readBearer, readCookie, readServiceCredential } from './gates'
|
|
34
|
+
export type { CompiledGate } from './gates'
|
|
35
|
+
export { stripFabrikaCookies } from './handoff'
|
|
36
|
+
export { HttpIamGateway, IamUnavailableError } from './iam'
|
|
37
|
+
export type { HttpIamGatewayOptions, IamGateway } from './iam'
|
|
38
|
+
export { consoleLogger, redactPath, redactUrl, silentLogger } from './log'
|
|
39
|
+
export type { LogFields, LogValue, ProxyLogger } from './log'
|
|
40
|
+
export { createVerifyService } from './service'
|
|
41
|
+
export type { VerifyService, VerifyServiceConfig } from './service'
|
|
42
|
+
export { TokenVerifier } from './verifier'
|
|
43
|
+
export type { VerifyResult } from './verifier'
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny structural readers for untrusted JSON (the deployed manifest, an IAM HTTP response body).
|
|
3
|
+
* They let the parsers narrow without `as` casts: read a field, check its runtime type, proceed.
|
|
4
|
+
* Mirrors `@fabrika/auth-core`'s internal `json.ts`, which is not exported from its entry point.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Read a property off an unknown value (undefined when absent / not an object). */
|
|
8
|
+
export function prop(value: unknown, key: string): unknown {
|
|
9
|
+
if (typeof value !== 'object' || value === null) {
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stringField(value: unknown, key: string): string | undefined {
|
|
16
|
+
const v = prop(value, key)
|
|
17
|
+
return typeof v === 'string' ? v : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function numberField(value: unknown, key: string): number | undefined {
|
|
21
|
+
const v = prop(value, key)
|
|
22
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function booleanField(value: unknown, key: string): boolean | undefined {
|
|
26
|
+
const v = prop(value, key)
|
|
27
|
+
return typeof v === 'boolean' ? v : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An array of strings, or undefined when the field is absent or any element is not a string. */
|
|
31
|
+
export function stringArrayField(value: unknown, key: string): string[] | undefined {
|
|
32
|
+
const v = prop(value, key)
|
|
33
|
+
if (!Array.isArray(v)) {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
const out: string[] = []
|
|
37
|
+
for (const item of v) {
|
|
38
|
+
if (typeof item !== 'string') {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
out.push(item)
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging, constrained so that secret material cannot reach it.
|
|
3
|
+
*
|
|
4
|
+
* The rule is structural rather than aspirational: a log field is a `string | number | boolean |
|
|
5
|
+
* null`, call sites pass only decision metadata, and the two values that CAN carry a credential —
|
|
6
|
+
* a request path (share-link tokens ride in the path) and a URL — go through `redactPath` /
|
|
7
|
+
* `redactUrl` first. Nothing in this package ever passes a header, a cookie, a token or a caught
|
|
8
|
+
* error object to a logger.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { API_KEY_PREFIX } from '@fabrika/auth-core'
|
|
12
|
+
|
|
13
|
+
export type LogValue = string | number | boolean | null
|
|
14
|
+
export type LogFields = Record<string, LogValue>
|
|
15
|
+
|
|
16
|
+
export interface ProxyLogger {
|
|
17
|
+
info(message: string, fields?: LogFields): void
|
|
18
|
+
warn(message: string, fields?: LogFields): void
|
|
19
|
+
error(message: string, fields?: LogFields): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Emits one JSON object per line — what Zerops' log collector expects. */
|
|
23
|
+
export const consoleLogger: ProxyLogger = {
|
|
24
|
+
info: (message, fields) => console.info(line('info', message, fields)),
|
|
25
|
+
warn: (message, fields) => console.warn(line('warn', message, fields)),
|
|
26
|
+
error: (message, fields) => console.error(line('error', message, fields)),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A logger that discards everything — the default, so a library consumer opts IN to output. */
|
|
30
|
+
export const silentLogger: ProxyLogger = {
|
|
31
|
+
info: () => {},
|
|
32
|
+
warn: () => {},
|
|
33
|
+
error: () => {},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function line(level: string, message: string, fields: LogFields | undefined): string {
|
|
37
|
+
return JSON.stringify({ level, message, ...fields })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Make a path safe to log. Two hazards, both real:
|
|
42
|
+
* - a query string can carry a declared `query` credential (`?pxt=<token>`) — dropped entirely;
|
|
43
|
+
* - a path SEGMENT can be a share-link `px_` credential — replaced with `px_***`.
|
|
44
|
+
* Anything else is preserved, because an unloggable path is an undebuggable proxy.
|
|
45
|
+
*/
|
|
46
|
+
export function redactPath(pathname: string): string {
|
|
47
|
+
const query = pathname.indexOf('?')
|
|
48
|
+
const path = query === -1 ? pathname : pathname.slice(0, query)
|
|
49
|
+
return path
|
|
50
|
+
.split('/')
|
|
51
|
+
.map((segment) => (segment.startsWith(API_KEY_PREFIX) ? `${API_KEY_PREFIX}***` : segment))
|
|
52
|
+
.join('/')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Origin + redacted path. Never the query string. */
|
|
56
|
+
export function redactUrl(url: URL): string {
|
|
57
|
+
return `${url.origin}${redactPath(url.pathname)}`
|
|
58
|
+
}
|