@nifrajs/core 5.0.0 → 5.2.0
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/CHANGELOG.md +2384 -0
- package/dist/assurance.d.ts +15 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +97 -71
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +17 -1
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +20 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-program.d.ts +2 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +22 -17
- package/dist/internal/route-program.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/reflection.d.ts +23 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +57 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +7 -4
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +7 -1
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +37 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/headers.d.ts +9 -0
- package/dist/server/headers.d.ts.map +1 -1
- package/dist/server/headers.js +26 -0
- package/dist/server/headers.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +57 -7
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +394 -23
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -9
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +40 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +188 -43
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +11 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +26 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +9 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +81 -10
- package/dist/single-copy.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +2 -1
- package/dist/ws.js.map +1 -1
- package/package.json +3 -2
- package/src/assurance.ts +120 -81
- package/src/capabilities.ts +3 -1
- package/src/edge-kit.ts +1 -1
- package/src/index.ts +2 -2
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +39 -20
- package/src/internal/same-origin.ts +14 -1
- package/src/reflection.ts +61 -1
- package/src/server/body-lane.ts +36 -1
- package/src/server/client-ip.ts +5 -5
- package/src/server/context.ts +7 -4
- package/src/server/cookies.ts +12 -1
- package/src/server/core-copies.ts +40 -0
- package/src/server/edge.ts +2 -1
- package/src/server/headers.ts +39 -0
- package/src/server/http.ts +7 -1
- package/src/server/response-contract-lane.ts +491 -24
- package/src/server/server-types.ts +23 -9
- package/src/server/server.ts +249 -35
- package/src/server/websocket.ts +39 -13
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +9 -2
- package/src/server.ts +1 -1
- package/src/single-copy.ts +90 -9
- package/src/wire.ts +14 -0
- package/src/ws.ts +2 -0
|
@@ -16,6 +16,10 @@ export type HookKind =
|
|
|
16
16
|
| "onResponse"
|
|
17
17
|
| "responseHeaders"
|
|
18
18
|
| "onResponseFinalized"
|
|
19
|
+
// Runtimes a route resolved at registration: reflected beside its hooks, never tagged as one.
|
|
20
|
+
| "effectLedger"
|
|
21
|
+
| "idempotency"
|
|
22
|
+
| "responseContract"
|
|
19
23
|
|
|
20
24
|
export interface HookOrigin {
|
|
21
25
|
readonly kind: HookKind
|
|
@@ -48,11 +52,24 @@ export interface AdoptionTag {
|
|
|
48
52
|
readonly prefix: string | undefined
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/** A `merge()` whose routes carry no authentication of their own while the parent authenticates the
|
|
56
|
+
* routes it declares: the parent's order-scoped auth never runs for merged routes. */
|
|
57
|
+
export interface MergeAuthGapTag {
|
|
58
|
+
/** The `merge()` call's `file:line:col`, when a frame outside core was found. */
|
|
59
|
+
readonly site: string | undefined
|
|
60
|
+
/** The parent's auth in effect at the merge: `authenticate(<id>)` stages and evidence sources. */
|
|
61
|
+
readonly auth: readonly string[]
|
|
62
|
+
/** `METHOD /path` of each merged route the parent's auth would have covered. */
|
|
63
|
+
readonly routes: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
const origins = new WeakMap<object, HookOrigin>()
|
|
52
67
|
const scopes = new WeakMap<object, HookScopeTag>()
|
|
53
68
|
const sources = new WeakMap<object, unknown>()
|
|
54
69
|
const statics = new WeakMap<object, StaticHeadersTag[]>()
|
|
55
70
|
const adoptions = new WeakMap<object, AdoptionTag[]>()
|
|
71
|
+
const mergeAuthGaps = new WeakMap<object, MergeAuthGapTag[]>()
|
|
72
|
+
const routeSites = new WeakMap<object, Map<string, string>>()
|
|
56
73
|
let applying: string | undefined
|
|
57
74
|
// A merged server whose static headers are about to become one of the target's response hooks.
|
|
58
75
|
let staticsOwner: object | undefined
|
|
@@ -130,6 +147,20 @@ export function tagAdoption(target: object, source: object, prefix: string | und
|
|
|
130
147
|
else list.push(tag)
|
|
131
148
|
}
|
|
132
149
|
|
|
150
|
+
export function tagMergeAuthGap(target: object, gap: MergeAuthGapTag): void {
|
|
151
|
+
const list = mergeAuthGaps.get(target)
|
|
152
|
+
if (list === undefined) mergeAuthGaps.set(target, [gap])
|
|
153
|
+
else list.push(gap)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Where `server` registered the route `key` (`METHOD /path`): a `file:line:col` frame. The first
|
|
157
|
+
* registration of a key wins, as it does for the router. */
|
|
158
|
+
export function tagRouteSite(server: object, key: string, site: string): void {
|
|
159
|
+
const sites = routeSites.get(server)
|
|
160
|
+
if (sites === undefined) routeSites.set(server, new Map([[key, site]]))
|
|
161
|
+
else if (!sites.has(key)) sites.set(key, site)
|
|
162
|
+
}
|
|
163
|
+
|
|
133
164
|
const outer: (string | undefined)[] = []
|
|
134
165
|
|
|
135
166
|
/** Mark the plugin a `use()` is applying, until the matching {@link leavePlugin}. */
|
|
@@ -151,3 +182,7 @@ export const hookSourceOf = (hook: unknown): unknown =>
|
|
|
151
182
|
export const staticHeadersOf = (server: object): readonly StaticHeadersTag[] =>
|
|
152
183
|
statics.get(server) ?? []
|
|
153
184
|
export const adoptionsOf = (server: object): readonly AdoptionTag[] => adoptions.get(server) ?? []
|
|
185
|
+
export const mergeAuthGapsOf = (server: object): readonly MergeAuthGapTag[] =>
|
|
186
|
+
mergeAuthGaps.get(server) ?? []
|
|
187
|
+
export const routeSitesOf = (server: object): ReadonlyMap<string, string> =>
|
|
188
|
+
routeSites.get(server) ?? new Map()
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
NIFRA_ASSURANCE_IDS,
|
|
36
36
|
validEvidenceId,
|
|
37
37
|
} from "./route-assurance.ts"
|
|
38
|
+
import type { CatalogRoute } from "./route-catalog.ts"
|
|
38
39
|
import type {
|
|
39
40
|
RawAfterHandle,
|
|
40
41
|
RawAround,
|
|
@@ -43,7 +44,9 @@ import type {
|
|
|
43
44
|
RawDerive,
|
|
44
45
|
RawErrorHandler,
|
|
45
46
|
} from "./route-execution.ts"
|
|
47
|
+
import { compileRouteExecutionPlan } from "./route-execution.ts"
|
|
46
48
|
import { type RouteLaneSelection, selectRouteLanes } from "./route-lanes.ts"
|
|
49
|
+
import { compileRouteProgram } from "./route-program.ts"
|
|
47
50
|
|
|
48
51
|
export interface RouteCompilerContext {
|
|
49
52
|
readonly maxBodyBytes: number
|
|
@@ -83,6 +86,108 @@ export interface CompiledRouteOptions {
|
|
|
83
86
|
readonly authenticated: boolean
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
const RESPONSE_CONTRACT_EVIDENCE: AssuranceDeclaration = Object.freeze({
|
|
90
|
+
id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
|
|
91
|
+
source: "response-contract",
|
|
92
|
+
scope: "plugin",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function responseDefinitionOf(
|
|
96
|
+
schema: RouteSchema | undefined,
|
|
97
|
+
): ResponseContractDefinition | undefined {
|
|
98
|
+
return schema?.response !== undefined || schema?.errors !== undefined
|
|
99
|
+
? {
|
|
100
|
+
...(schema.response === undefined ? {} : { response: schema.response }),
|
|
101
|
+
...(schema.errors === undefined ? {} : { errors: schema.errors }),
|
|
102
|
+
}
|
|
103
|
+
: undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Recompile a route another server compiled, under the response contract and effect ledger the
|
|
108
|
+
* adopting server installed and the route's own server did not. Both only add a check or a record,
|
|
109
|
+
* so a merged route can never serve more than it did standalone - and without this, the same
|
|
110
|
+
* `.use()` covered a `group()` route but not a `merge()`d one. A route that already resolved its
|
|
111
|
+
* own contract or ledger keeps it.
|
|
112
|
+
*/
|
|
113
|
+
export function adoptRouteRuntimes(
|
|
114
|
+
route: CatalogRoute,
|
|
115
|
+
responseContractRuntime: ResponseContractRuntime | undefined,
|
|
116
|
+
effectLedgerRuntime: EffectLedgerRuntime | undefined,
|
|
117
|
+
): CatalogRoute {
|
|
118
|
+
const { entry, descriptor } = route
|
|
119
|
+
const definition =
|
|
120
|
+
entry.responseContract === undefined && responseContractRuntime !== undefined
|
|
121
|
+
? responseDefinitionOf(entry.schema)
|
|
122
|
+
: undefined
|
|
123
|
+
const ledgered =
|
|
124
|
+
entry.ledgered ??
|
|
125
|
+
effectLedgerRuntime?.resolve(descriptor.capabilities ?? [], route.method, route.path)
|
|
126
|
+
if (definition === undefined && ledgered === entry.ledgered) return route
|
|
127
|
+
const responseContract =
|
|
128
|
+
definition === undefined || responseContractRuntime === undefined
|
|
129
|
+
? entry.responseContract
|
|
130
|
+
: { runtime: responseContractRuntime, definition }
|
|
131
|
+
// Either runtime rules out every fused lane, so the route needs only the generic plan here - and
|
|
132
|
+
// nothing the source server's validation-error default decided about fusion can matter any more.
|
|
133
|
+
const lanes = selectRouteLanes({
|
|
134
|
+
schema: entry.schema,
|
|
135
|
+
hasAuth: entry.authStages.length > 0,
|
|
136
|
+
hasIdempotency: entry.idempotent !== undefined,
|
|
137
|
+
hasLedger: ledgered !== undefined,
|
|
138
|
+
hasResponseContract: responseContract !== undefined,
|
|
139
|
+
hasDecorations: entry.hasDecorations,
|
|
140
|
+
derives: entry.derives.length,
|
|
141
|
+
beforeHandle: entry.beforeHandle.length,
|
|
142
|
+
afterHandle: entry.afterHandle.length,
|
|
143
|
+
onError: entry.onError.length,
|
|
144
|
+
around: entry.around.length,
|
|
145
|
+
defaultOnValidationError: false,
|
|
146
|
+
})
|
|
147
|
+
const program = compileRouteProgram({
|
|
148
|
+
schema: entry.schema,
|
|
149
|
+
handler: entry.handler,
|
|
150
|
+
derives: entry.derives,
|
|
151
|
+
authStages: entry.authStages,
|
|
152
|
+
beforeHandle: entry.beforeHandle,
|
|
153
|
+
afterHandle: entry.afterHandle,
|
|
154
|
+
onError: entry.onError,
|
|
155
|
+
decorations: entry.decorations,
|
|
156
|
+
hasDecorations: entry.hasDecorations,
|
|
157
|
+
bodySchema: entry.schema?.body,
|
|
158
|
+
bodyLimit: entry.bodyLimit,
|
|
159
|
+
responseContract,
|
|
160
|
+
})
|
|
161
|
+
const execution = compileRouteExecutionPlan({
|
|
162
|
+
lane: lanes.lane,
|
|
163
|
+
contextless: false,
|
|
164
|
+
hasAround: entry.around.length > 0,
|
|
165
|
+
hasLedger: ledgered !== undefined,
|
|
166
|
+
fusedWeb: undefined,
|
|
167
|
+
fusedBody: undefined,
|
|
168
|
+
fusedLane: undefined,
|
|
169
|
+
})
|
|
170
|
+
const enforced = definition !== undefined && responseContractRuntime?.mode === "enforce"
|
|
171
|
+
return {
|
|
172
|
+
...route,
|
|
173
|
+
entry: {
|
|
174
|
+
...entry,
|
|
175
|
+
ledgered,
|
|
176
|
+
responseContract,
|
|
177
|
+
lifecycleHookLane: undefined,
|
|
178
|
+
execution,
|
|
179
|
+
program,
|
|
180
|
+
},
|
|
181
|
+
descriptor:
|
|
182
|
+
definition === undefined || responseContractRuntime === undefined
|
|
183
|
+
? descriptor
|
|
184
|
+
: { ...descriptor, responseContract: responseContractRuntime.mode },
|
|
185
|
+
assurance: enforced
|
|
186
|
+
? Object.freeze([...route.assurance, RESPONSE_CONTRACT_EVIDENCE])
|
|
187
|
+
: route.assurance,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
86
191
|
/** Resolve all registration-time policy and lane facts for one route. */
|
|
87
192
|
export function compileRouteOptions(
|
|
88
193
|
context: RouteCompilerContext,
|
|
@@ -181,13 +286,7 @@ export function compileRouteOptions(
|
|
|
181
286
|
context.maxBodyBytes,
|
|
182
287
|
)
|
|
183
288
|
const ledgered = context.effectLedgerRuntime?.resolve(capabilities, method, path)
|
|
184
|
-
const responseDefinition =
|
|
185
|
-
schema?.response !== undefined || schema?.errors !== undefined
|
|
186
|
-
? {
|
|
187
|
-
...(schema.response === undefined ? {} : { response: schema.response }),
|
|
188
|
-
...(schema.errors === undefined ? {} : { errors: schema.errors }),
|
|
189
|
-
}
|
|
190
|
-
: undefined
|
|
289
|
+
const responseDefinition = responseDefinitionOf(schema)
|
|
191
290
|
const contracted =
|
|
192
291
|
context.responseContractRuntime !== undefined && responseDefinition !== undefined
|
|
193
292
|
? { runtime: context.responseContractRuntime, definition: responseDefinition }
|
|
@@ -208,15 +307,7 @@ export function compileRouteOptions(
|
|
|
208
307
|
})
|
|
209
308
|
|
|
210
309
|
const routeAssurance: AssuranceDeclaration[] = [...context.activeAssurance, ...handlerAssurance]
|
|
211
|
-
if (contracted?.runtime.mode === "enforce")
|
|
212
|
-
routeAssurance.push(
|
|
213
|
-
Object.freeze({
|
|
214
|
-
id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
|
|
215
|
-
source: "response-contract",
|
|
216
|
-
scope: "plugin",
|
|
217
|
-
}),
|
|
218
|
-
)
|
|
219
|
-
}
|
|
310
|
+
if (contracted?.runtime.mode === "enforce") routeAssurance.push(RESPONSE_CONTRACT_EVIDENCE)
|
|
220
311
|
for (const id of schema?.assurance ?? []) {
|
|
221
312
|
if (!validEvidenceId(id)) {
|
|
222
313
|
throw new Error(
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
11
11
|
import { markAuthenticatedRequest } from "../server/auth.ts"
|
|
12
|
+
import { declaredHeaderNames } from "../server/headers.ts"
|
|
12
13
|
import { pathnameOf } from "../server/http.ts"
|
|
13
14
|
import { queryObjectOf } from "../server/query.ts"
|
|
14
15
|
import type { Registry } from "../server/registry.ts"
|
|
@@ -35,7 +36,12 @@ import type {
|
|
|
35
36
|
export type ProgramValidationKind = "headers" | "cookies" | "params" | "body" | "query"
|
|
36
37
|
|
|
37
38
|
export type RouteProgramStage =
|
|
38
|
-
| {
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: ProgramValidationKind
|
|
41
|
+
readonly schema: StandardSchemaV1
|
|
42
|
+
/** Headers only: the names a closed schema validates, read case-insensitively. */
|
|
43
|
+
readonly names?: readonly string[] | undefined
|
|
44
|
+
}
|
|
39
45
|
| { readonly kind: "derive"; readonly run: RawDerive }
|
|
40
46
|
| { readonly kind: "before"; readonly run: RawBeforeHandle }
|
|
41
47
|
| { readonly kind: "handler"; readonly run: InternalHandler }
|
|
@@ -90,7 +96,8 @@ export function compileRouteProgram(input: RouteProgramInput): RouteProgram {
|
|
|
90
96
|
for (const kind of ["headers", "cookies", "params", "body", "query"] as const) {
|
|
91
97
|
const validator = schema?.[kind]
|
|
92
98
|
if (validator !== undefined) {
|
|
93
|
-
const
|
|
99
|
+
const names = kind === "headers" ? declaredHeaderNames(validator) : undefined
|
|
100
|
+
const stage = { kind, schema: validator, names }
|
|
94
101
|
stages.push(stage)
|
|
95
102
|
validationStages.push(stage)
|
|
96
103
|
}
|
|
@@ -142,6 +149,7 @@ interface RouteProgramRuntime {
|
|
|
142
149
|
ctx: RawContext,
|
|
143
150
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
144
151
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
152
|
+
fallback?: () => T,
|
|
145
153
|
): MaybePromise<T>
|
|
146
154
|
finishProgramResult<T>(
|
|
147
155
|
entry: RouteEntry,
|
|
@@ -243,6 +251,21 @@ function validAuthenticationFailureReason(
|
|
|
243
251
|
)
|
|
244
252
|
}
|
|
245
253
|
|
|
254
|
+
/** A failed stage still answers 503, but its cause takes the handler-error path (onError, else the
|
|
255
|
+
* logger) instead of vanishing. */
|
|
256
|
+
function failAuthentication<T>(
|
|
257
|
+
host: RouteProgramRuntime,
|
|
258
|
+
entry: RouteEntry,
|
|
259
|
+
ctx: RawContext,
|
|
260
|
+
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
261
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
262
|
+
error: unknown,
|
|
263
|
+
): ProgramResult<T> {
|
|
264
|
+
return host.handleProgramError(entry, error, ctx, finalize, wrapResponse, () =>
|
|
265
|
+
finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx),
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
246
269
|
function runAuthenticationStages<T>(
|
|
247
270
|
host: RouteProgramRuntime,
|
|
248
271
|
entry: RouteEntry,
|
|
@@ -287,8 +310,8 @@ function runAuthenticationStages<T>(
|
|
|
287
310
|
let outcome: MaybePromise<import("../server/auth.ts").AuthenticationResult<unknown>>
|
|
288
311
|
try {
|
|
289
312
|
outcome = stage.run(input)
|
|
290
|
-
} catch {
|
|
291
|
-
return
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
|
|
292
315
|
}
|
|
293
316
|
if (outcome instanceof Promise) {
|
|
294
317
|
return outcome.then(
|
|
@@ -312,7 +335,7 @@ function runAuthenticationStages<T>(
|
|
|
312
335
|
return host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
|
|
313
336
|
}
|
|
314
337
|
},
|
|
315
|
-
() =>
|
|
338
|
+
(error) => failAuthentication(host, entry, ctx, finalize, wrapResponse, error),
|
|
316
339
|
)
|
|
317
340
|
}
|
|
318
341
|
return finishAuthentication(
|
|
@@ -344,27 +367,23 @@ function finishAuthentication<T>(
|
|
|
344
367
|
if (
|
|
345
368
|
result === null ||
|
|
346
369
|
typeof result !== "object" ||
|
|
347
|
-
(result.kind
|
|
370
|
+
(result.kind === "rejected"
|
|
371
|
+
? !validAuthenticationFailureReason(result.reason) ||
|
|
372
|
+
(result.response !== undefined &&
|
|
373
|
+
!(result.response instanceof Response) &&
|
|
374
|
+
!isResponseResult(result.response))
|
|
375
|
+
: result.kind !== "authenticated" ||
|
|
376
|
+
result.principal === null ||
|
|
377
|
+
result.principal === undefined)
|
|
348
378
|
) {
|
|
349
|
-
|
|
379
|
+
const id = program.authStages[next - 1]!.id
|
|
380
|
+
const error = new TypeError(`authenticate() stage "${id}" returned an invalid decision`)
|
|
381
|
+
return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
|
|
350
382
|
}
|
|
351
383
|
if (result.kind === "rejected") {
|
|
352
|
-
if (!validAuthenticationFailureReason(result.reason)) {
|
|
353
|
-
return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
|
|
354
|
-
}
|
|
355
|
-
if (
|
|
356
|
-
result.response !== undefined &&
|
|
357
|
-
!(result.response instanceof Response) &&
|
|
358
|
-
!isResponseResult(result.response)
|
|
359
|
-
) {
|
|
360
|
-
return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
|
|
361
|
-
}
|
|
362
384
|
const response = result.response ?? defaultAuthenticationResponse(result.reason)
|
|
363
385
|
return finalize(response, responseSet(ctx), ctx)
|
|
364
386
|
}
|
|
365
|
-
if (result.principal === null || result.principal === undefined) {
|
|
366
|
-
return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
|
|
367
|
-
}
|
|
368
387
|
Object.defineProperty(ctx, "principal", {
|
|
369
388
|
value: result.principal,
|
|
370
389
|
enumerable: true,
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*
|
|
27
27
|
* An unparseable Origin is not same-origin.
|
|
28
28
|
*/
|
|
29
|
-
export function isSameOriginRequest(origin: string, request:
|
|
29
|
+
export function isSameOriginRequest(origin: string, request: { readonly url: string }): boolean {
|
|
30
30
|
try {
|
|
31
31
|
const from = new URL(origin)
|
|
32
32
|
const own = new URL(request.url)
|
|
@@ -41,3 +41,16 @@ export function isSameOriginRequest(origin: string, request: Request): boolean {
|
|
|
41
41
|
return false
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* {@link isSameOriginRequest} for a browser form submission. Under `Referrer-Policy: no-referrer` a
|
|
47
|
+
* browser sends a same-origin form POST with `Origin: null`; that one passes on its
|
|
48
|
+
* `Sec-Fetch-Site: same-origin`, a header page script cannot set.
|
|
49
|
+
*/
|
|
50
|
+
export function isSameOriginForm(
|
|
51
|
+
origin: string,
|
|
52
|
+
secFetchSite: string | null,
|
|
53
|
+
request: { readonly url: string },
|
|
54
|
+
): boolean {
|
|
55
|
+
return origin === "null" ? secFetchSite === "same-origin" : isSameOriginRequest(origin, request)
|
|
56
|
+
}
|
package/src/reflection.ts
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
hookOriginOf,
|
|
21
21
|
hookScopeOf,
|
|
22
22
|
hookSourceOf,
|
|
23
|
+
type MergeAuthGapTag,
|
|
24
|
+
mergeAuthGapsOf,
|
|
25
|
+
routeSitesOf,
|
|
23
26
|
staticHeadersOf,
|
|
24
27
|
} from "./internal/hook-origins.ts"
|
|
25
28
|
import {
|
|
@@ -33,6 +36,8 @@ import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
|
|
|
33
36
|
import type { StandardSchemaV1 } from "./schema/standard.ts"
|
|
34
37
|
import type { ToolAnnotations } from "./server/server.ts"
|
|
35
38
|
|
|
39
|
+
export { declaredHeaderNames } from "./server/headers.ts"
|
|
40
|
+
|
|
36
41
|
/** JSON Schema permits either a schema object or the boolean schemas `true` and `false`. */
|
|
37
42
|
export type JsonSchema = boolean | Readonly<Record<string, unknown>>
|
|
38
43
|
|
|
@@ -460,7 +465,9 @@ export interface ReflectedHook {
|
|
|
460
465
|
readonly kind: HookKind
|
|
461
466
|
/** The plugin that registered it, else the function's own name, else `(anonymous)`. */
|
|
462
467
|
readonly name: string
|
|
463
|
-
/** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown
|
|
468
|
+
/** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`; `route`
|
|
469
|
+
* for a runtime the route resolved at registration (`responseContract`, `effectLedger`,
|
|
470
|
+
* `idempotency`), whose `name` is the contract mode or the runtime. */
|
|
464
471
|
readonly site: string
|
|
465
472
|
/** The header names a static `responseHeaders()` declaration sets. */
|
|
466
473
|
readonly headers?: readonly string[]
|
|
@@ -559,6 +566,51 @@ const catalogOf = (
|
|
|
559
566
|
const joinPath = (base: string, path: string): string =>
|
|
560
567
|
base === "" ? path : path === "/" ? base : `${base}${path}`
|
|
561
568
|
|
|
569
|
+
/** A `merge()` that authenticates none of its routes under a parent that authenticates its own. */
|
|
570
|
+
export type ReflectedMergeAuthGap = MergeAuthGapTag
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Every `merge()` in the app, its merged servers included, whose server authenticates none of its
|
|
574
|
+
* routes while the parent's order-scoped auth - an `authenticate()` stage, or `authenticated` evidence
|
|
575
|
+
* scoped to subsequent routes - covers the parent's own: that auth never runs for a merged route.
|
|
576
|
+
* Recorded by a development load only (the same provenance {@link reflectHooks} reads), so reflect the
|
|
577
|
+
* app outside production. `nifra check` reports each one (NF-C043).
|
|
578
|
+
*/
|
|
579
|
+
export function reflectMergeAuthGaps(source: unknown): readonly ReflectedMergeAuthGap[] {
|
|
580
|
+
if (!isServerRecord(source)) return []
|
|
581
|
+
const gaps: ReflectedMergeAuthGap[] = []
|
|
582
|
+
const seen = new Set<object>()
|
|
583
|
+
const visit = (server: object): void => {
|
|
584
|
+
if (seen.has(server)) return
|
|
585
|
+
seen.add(server)
|
|
586
|
+
gaps.push(...mergeAuthGapsOf(server))
|
|
587
|
+
for (const adoption of adoptionsOf(server)) visit(adoption.source)
|
|
588
|
+
}
|
|
589
|
+
visit(source)
|
|
590
|
+
return gaps
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Where each route was registered: `METHOD /path` (`WS` for a socket) mapped to the `file:line:col` of
|
|
595
|
+
* the call outside nifra's core that registered it, through every `group()` and `merge()`. Recorded only
|
|
596
|
+
* by a development load with `NIFRA_ROUTE_SITES=1` (the CLI sets it while it loads `nifra.assurance.ts`),
|
|
597
|
+
* so the map is empty otherwise. The paths are absolute, which is why they stay out of
|
|
598
|
+
* {@link reflectRoutes}, whose output is written into artifacts.
|
|
599
|
+
*/
|
|
600
|
+
export function reflectRouteSites(source: unknown): ReadonlyMap<string, string> {
|
|
601
|
+
const sites = new Map<string, string>()
|
|
602
|
+
if (!isServerRecord(source)) return sites
|
|
603
|
+
const seen = new Set<object>()
|
|
604
|
+
const visit = (server: object): void => {
|
|
605
|
+
if (seen.has(server)) return
|
|
606
|
+
seen.add(server)
|
|
607
|
+
for (const [key, site] of routeSitesOf(server)) if (!sites.has(key)) sites.set(key, site)
|
|
608
|
+
for (const adoption of adoptionsOf(server)) visit(adoption.source)
|
|
609
|
+
}
|
|
610
|
+
visit(source)
|
|
611
|
+
return sites
|
|
612
|
+
}
|
|
613
|
+
|
|
562
614
|
/**
|
|
563
615
|
* Every hook that actually runs for each route, and for each prefix's unmatched paths, with the plugin
|
|
564
616
|
* that registered it and where: the app, a `group()`, a `merge()`d server, or a `mount()`ed one. It
|
|
@@ -698,16 +750,24 @@ export function reflectHooks(source: unknown): ReflectedHookReport {
|
|
|
698
750
|
const entry = recordOf(route.entry)
|
|
699
751
|
const chain = (field: string, kind: HookKind) =>
|
|
700
752
|
entry === undefined ? [] : listOf(entry, field).flatMap((hook) => entriesOf(hook, kind))
|
|
753
|
+
// A runtime is pinned into the route when it registers (or is merged), so it is listed with the
|
|
754
|
+
// route's site rather than a hook's; the mode is the name so a warn -> enforce change shows.
|
|
755
|
+
const mode = recordOf(recordOf(entry?.responseContract)?.runtime)?.mode
|
|
756
|
+
const pinned = (present: boolean, kind: HookKind, name: string): ReflectedHook[] =>
|
|
757
|
+
present ? [{ kind, name, site: "route" }] : []
|
|
701
758
|
routes.push({
|
|
702
759
|
method: route.method,
|
|
703
760
|
path: reportPath(route.path),
|
|
704
761
|
hooks: [
|
|
705
762
|
...requestPhase(probes),
|
|
706
763
|
...chain("around", "around"),
|
|
764
|
+
...pinned(entry?.ledgered !== undefined, "effectLedger", "effectLedger"),
|
|
707
765
|
...chain("authStages", "authenticate"),
|
|
708
766
|
...chain("derives", "derive"),
|
|
709
767
|
...chain("beforeHandle", "beforeHandle"),
|
|
768
|
+
...pinned(entry?.idempotent !== undefined, "idempotency", "idempotency"),
|
|
710
769
|
...chain("afterHandle", "afterHandle"),
|
|
770
|
+
...pinned(typeof mode === "string", "responseContract", String(mode)),
|
|
711
771
|
...chain("onError", "onError"),
|
|
712
772
|
...responsePhase(probes),
|
|
713
773
|
],
|
package/src/server/body-lane.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* reuse the exact trust-boundary lane without bundling the full context implementation (cookies,
|
|
4
4
|
* deferred responses, and request helpers).
|
|
5
5
|
*/
|
|
6
|
+
import { isSameOriginForm } from "../internal/same-origin.ts"
|
|
6
7
|
import { drainCapped, hasTrustedBodyFraming, parseContentLength, RAW_BODY_READERS } from "./body.ts"
|
|
7
8
|
import { plainError } from "./http.ts"
|
|
8
9
|
import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
@@ -191,10 +192,39 @@ export type SchemaBodyReader = ((
|
|
|
191
192
|
readonly mediaTypes?: readonly string[]
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Origins besides the request's own whose HTML forms may post a body: exact origins, or a function
|
|
197
|
+
* that receives the request's `Origin`.
|
|
198
|
+
*/
|
|
199
|
+
export type FormOrigins = ReadonlyArray<string> | ((origin: string) => boolean)
|
|
200
|
+
|
|
201
|
+
/** The media types a cross-site HTML form can send with the visitor's cookies and no preflight. */
|
|
202
|
+
function isFormMediaType(contentType: string): boolean {
|
|
203
|
+
const semi = contentType.indexOf(";")
|
|
204
|
+
const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
205
|
+
return (
|
|
206
|
+
essence === "application/x-www-form-urlencoded" ||
|
|
207
|
+
essence === "multipart/form-data" ||
|
|
208
|
+
essence === "text/plain"
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formOriginAllowed(source: RequestSource, formOrigins: FormOrigins | undefined): boolean {
|
|
213
|
+
const origin = headerOf(source, "origin")
|
|
214
|
+
if (origin === null || isSameOriginForm(origin, headerOf(source, "sec-fetch-site"), source)) {
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
if (formOrigins === undefined) return false
|
|
218
|
+
return typeof formOrigins === "function"
|
|
219
|
+
? formOrigins(origin) === true
|
|
220
|
+
: formOrigins.includes(origin)
|
|
221
|
+
}
|
|
222
|
+
|
|
194
223
|
/**
|
|
195
224
|
* The shared content-type dispatcher around the JSON and urlencoded lanes. A `bodySchema` branded
|
|
196
225
|
* with {@link SCHEMA_BODY_READER} takes every other media type; JSON and urlencoded bodies keep
|
|
197
|
-
* their lanes whatever the schema carries.
|
|
226
|
+
* their lanes whatever the schema carries. A form media type whose `Origin` is another site's, and
|
|
227
|
+
* not one of `formOrigins`, is refused with `403` before the body is read.
|
|
198
228
|
*/
|
|
199
229
|
export function readBodyFramed<T>(
|
|
200
230
|
source: RequestSource,
|
|
@@ -204,9 +234,14 @@ export function readBodyFramed<T>(
|
|
|
204
234
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
205
235
|
onError: (err: unknown) => MaybePromise<T>,
|
|
206
236
|
bodySchema?: object,
|
|
237
|
+
formOrigins?: FormOrigins,
|
|
207
238
|
): Promise<T> {
|
|
208
239
|
const contentType = headerOf(source, "content-type") ?? ""
|
|
209
240
|
if (!isJsonMediaType(contentType)) {
|
|
241
|
+
// JSON needs a preflight, so CORS judges it; a form body reaches here from any site with cookies.
|
|
242
|
+
if (isFormMediaType(contentType) && !formOriginAllowed(source, formOrigins)) {
|
|
243
|
+
return Promise.resolve(wrapResponse(plainError(403, "forbidden_origin")))
|
|
244
|
+
}
|
|
210
245
|
if (isUrlEncodedForm(contentType)) {
|
|
211
246
|
return readBoundedForm(source, maxBodyBytes).then(
|
|
212
247
|
(form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
|
package/src/server/client-ip.ts
CHANGED
|
@@ -56,7 +56,9 @@ export function resolveClientIp(
|
|
|
56
56
|
if (trust === undefined) return peer
|
|
57
57
|
if ("header" in trust) return firstHeaderValue(req, trust.header)
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
if (trust.trustedHops === 0) return peer
|
|
60
|
+
// Hop 0 is the socket peer and hop k the k-th XFF entry from the right, so the caller is read from the
|
|
61
|
+
// header alone: a peer-less request (Workers, a unix socket) must not shift an attacker's entry in.
|
|
60
62
|
const xff = req.headers.get("x-forwarded-for")
|
|
61
63
|
const forwarded =
|
|
62
64
|
xff === null
|
|
@@ -65,8 +67,6 @@ export function resolveClientIp(
|
|
|
65
67
|
.split(",")
|
|
66
68
|
.map((entry) => entry.trim())
|
|
67
69
|
.filter((entry) => entry !== "")
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const index = chain.length - 1 - trust.trustedHops
|
|
71
|
-
return index >= 0 ? chain[index] : undefined
|
|
70
|
+
const index = forwarded.length - trust.trustedHops
|
|
71
|
+
return index >= 0 ? forwarded[index] : undefined
|
|
72
72
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -312,9 +312,12 @@ export interface RouteSchema {
|
|
|
312
312
|
* The schema must cover every path param; with the strict default an undeclared path param is rejected.
|
|
313
313
|
* When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
|
|
314
314
|
readonly params?: StandardSchemaV1
|
|
315
|
-
/** Optional request-header schema
|
|
316
|
-
*
|
|
317
|
-
* case-
|
|
315
|
+
/** Optional request-header schema; the validated output is `c.headers` and a failure is a `422`.
|
|
316
|
+
* Every client sends headers a route never declares (`host`, `user-agent`, `accept`), so a closed
|
|
317
|
+
* object schema (`t.object`) validates only the names it declares, each read case-insensitively and
|
|
318
|
+
* keyed as declared. `t.headers` is the open form: it receives every header, names lower-cased,
|
|
319
|
+
* and coerces values like `t.query`. A schema that does not expose its JSON Schema (a zod
|
|
320
|
+
* `.strict()` or valibot `strictObject`) receives every header, so declare it open. */
|
|
318
321
|
readonly headers?: StandardSchemaV1
|
|
319
322
|
/** Optional request-cookie schema. It validates the cookies parsed from the `Cookie` header -
|
|
320
323
|
* names as sent, values URL-decoded strings, the first of a repeated name - before the handler
|
|
@@ -446,7 +449,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
446
449
|
/** Read one request header without materializing a Web `Request` on the Node adapter hot path. */
|
|
447
450
|
readonly header: (name: string) => string | null
|
|
448
451
|
readonly params: ParamsOf<S, Path>
|
|
449
|
-
/**
|
|
452
|
+
/** Request headers, names lower-cased; when `schema.headers` is declared, its validated output. */
|
|
450
453
|
readonly headers: HeadersOf<S>
|
|
451
454
|
readonly query: QueryOf<S>
|
|
452
455
|
readonly body: BodyOf<S>
|
package/src/server/cookies.ts
CHANGED
|
@@ -212,17 +212,28 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
|
|
|
212
212
|
|
|
213
213
|
const TEXT = new TextEncoder()
|
|
214
214
|
|
|
215
|
+
// Imported once per secret: every signed-cookie read and write, and every auth session check, signs or
|
|
216
|
+
// verifies. Bounded, since a secret can come from per-tenant configuration.
|
|
217
|
+
const HMAC_KEYS = new Map<string, Promise<CryptoKey>>()
|
|
218
|
+
const MAX_HMAC_KEYS = 64
|
|
219
|
+
|
|
215
220
|
const importHmacKey = (secret: string): Promise<CryptoKey> => {
|
|
221
|
+
const cached = HMAC_KEYS.get(secret)
|
|
222
|
+
if (cached !== undefined) return cached
|
|
216
223
|
// Reject a weak secret at the choke point both signing and verification flow through (so signed
|
|
217
224
|
// cookies AND @nifrajs/auth sessions, which delegate here via signValue/unsignValue, are covered).
|
|
218
225
|
requireSecretBytes(secret, "signed-cookie")
|
|
219
|
-
|
|
226
|
+
const key = crypto.subtle.importKey(
|
|
220
227
|
"raw",
|
|
221
228
|
TEXT.encode(secret),
|
|
222
229
|
{ name: "HMAC", hash: "SHA-256" },
|
|
223
230
|
false,
|
|
224
231
|
["sign", "verify"],
|
|
225
232
|
)
|
|
233
|
+
if (HMAC_KEYS.size >= MAX_HMAC_KEYS) HMAC_KEYS.clear()
|
|
234
|
+
HMAC_KEYS.set(secret, key)
|
|
235
|
+
key.catch(() => HMAC_KEYS.delete(secret))
|
|
236
|
+
return key
|
|
226
237
|
}
|
|
227
238
|
|
|
228
239
|
const toBase64Url = (buf: ArrayBuffer): string => {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
const registry = globalThis as unknown as Record<symbol, string[] | undefined>
|
|
12
12
|
const COPIES = Symbol.for("nifra.core.copies")
|
|
13
|
+
const VERSIONS = Symbol.for("nifra.core.copy-versions")
|
|
13
14
|
registry[COPIES] ??= []
|
|
14
15
|
const copies = registry[COPIES]
|
|
15
16
|
const here = String(import.meta.url).split("?")[0] as string
|
|
@@ -19,3 +20,42 @@ if (!copies.includes(here) && copies.push(here) > 1) {
|
|
|
19
20
|
`[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
|
|
20
21
|
)
|
|
21
22
|
}
|
|
23
|
+
|
|
24
|
+
/** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
|
|
25
|
+
* `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
|
|
26
|
+
const COPY_VERSION = "5.2.0"
|
|
27
|
+
|
|
28
|
+
/** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
|
|
29
|
+
* so a production bundle drops it with the version; a copy that built a server has recorded one. */
|
|
30
|
+
export function recordCopyVersion(): void {
|
|
31
|
+
versionsByUrl().set(here, COPY_VERSION)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The process-wide URL -> version map every copy shares. */
|
|
35
|
+
const versionsByUrl = (): Map<unknown, unknown> => {
|
|
36
|
+
const found: unknown = Reflect.get(globalThis, VERSIONS)
|
|
37
|
+
if (found instanceof Map) return found
|
|
38
|
+
const created = new Map<unknown, unknown>()
|
|
39
|
+
Reflect.set(globalThis, VERSIONS, created)
|
|
40
|
+
return created
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const labelled = (url: string): string => {
|
|
44
|
+
const version = versionsByUrl().get(url)
|
|
45
|
+
return typeof version === "string" ? `${url} (${version})` : url
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Why `merge()` refused a value that is not a server of this copy: when another copy is loaded, the
|
|
49
|
+
* value is most likely its server, so both copies are named, with their versions where recorded
|
|
50
|
+
* (copies before 5.2 record none), and the way to collapse them. Called only outside production. */
|
|
51
|
+
export function mergeRefusal(): string {
|
|
52
|
+
const base = "merge() requires a server() from this copy of @nifrajs/core"
|
|
53
|
+
const others = copies.filter((url) => url !== here)
|
|
54
|
+
if (others.length === 0) return base
|
|
55
|
+
return [
|
|
56
|
+
`${base}, and ${copies.length} copies are loaded:`,
|
|
57
|
+
` this copy: ${labelled(here)}`,
|
|
58
|
+
...others.map((url) => ` other copy: ${labelled(url)}`),
|
|
59
|
+
"A server() from another copy cannot be merged. Make every package that builds a server() resolve one copy: in a linked repository, install the same nifra version (`nifra upgrade <version> --write`, then `bun install`); `nifra doctor` names each importer.",
|
|
60
|
+
].join("\n")
|
|
61
|
+
}
|