@vouchington/utils 0.4.0 → 0.5.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/README.md +7 -0
- package/dist/http-body.d.mts +18 -0
- package/dist/http-body.mjs +86 -0
- package/dist/http-link-pagination.d.mts +13 -0
- package/dist/http-link-pagination.mjs +141 -0
- package/dist/moderation.d.mts +34 -0
- package/dist/moderation.mjs +49 -0
- package/package.json +16 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ import { createMoneyCatalog } from '@vouchington/utils/money'
|
|
|
9
9
|
import { serializeCookie } from '@vouchington/utils/cookies'
|
|
10
10
|
import { bestAcceptLanguageMatch } from '@vouchington/utils/language-tags'
|
|
11
11
|
import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
12
|
+
import { createReportInputParser } from '@vouchington/utils/moderation'
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
## Subpaths
|
|
@@ -35,6 +36,8 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
|
35
36
|
formatting, text normalization, title casing, and word/sentence helpers.
|
|
36
37
|
- `gtin`, `bigint-ids`, and `validation`: GTIN predicates, canonical positive PostgreSQL bigint IDs,
|
|
37
38
|
and basic email/UUID predicates.
|
|
39
|
+
- `moderation`: caller-configured report target, reason, identifier, note, and cross-field input
|
|
40
|
+
validation. Applications own catalogs, policy, error mapping, persistence, and HTTP behavior.
|
|
38
41
|
- `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
|
|
39
42
|
query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
|
|
40
43
|
- `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
|
|
@@ -44,6 +47,10 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
|
44
47
|
- `hashtags`: caller-configured authored/key length and separator policy for canonical ASCII hashtag
|
|
45
48
|
keys. The package does not choose application limits or accepted input separators.
|
|
46
49
|
- `fetch-ports`: the Fetch standard forbidden-port list and membership predicates.
|
|
50
|
+
- `http-body`: bounded Fetch response-body reads with abort propagation and typed missing/oversize
|
|
51
|
+
errors.
|
|
52
|
+
- `http-link-pagination`: fail-closed RFC 8288 next-link parsing with same-origin URL and
|
|
53
|
+
unambiguous cursor extraction.
|
|
47
54
|
- `language-tags`: caller-configured locale normalization and strict `Accept-Language` parsing and
|
|
48
55
|
best matching. HTTP ranges reject malformed syntax and quality parameters; explicit `q=0` ranges
|
|
49
56
|
exclude a candidate when they tie for that candidate's most-specific matching range, so `en;q=0`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ReadResponseBodyOptions {
|
|
2
|
+
readonly response: Response;
|
|
3
|
+
readonly url: string;
|
|
4
|
+
readonly maxSizeBytes: number;
|
|
5
|
+
readonly signal?: AbortSignal;
|
|
6
|
+
}
|
|
7
|
+
export declare class MissingResponseBodyError extends Error {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
constructor(url: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class ResponseBodyTooLargeError extends Error {
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly sizeBytes: number;
|
|
14
|
+
readonly maxSizeBytes: number;
|
|
15
|
+
constructor(url: string, sizeBytes: number, maxSizeBytes: number);
|
|
16
|
+
}
|
|
17
|
+
export declare function readResponseBodyAsBuffer({ response, url, maxSizeBytes, signal, }: ReadResponseBodyOptions): Promise<Buffer>;
|
|
18
|
+
export declare function readResponseBody(options: ReadResponseBodyOptions): Promise<string>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export class MissingResponseBodyError extends Error {
|
|
2
|
+
url;
|
|
3
|
+
constructor(url) {
|
|
4
|
+
super(`HTTP response from ${url} has no body`);
|
|
5
|
+
this.name = 'MissingResponseBodyError';
|
|
6
|
+
this.url = url;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class ResponseBodyTooLargeError extends Error {
|
|
10
|
+
url;
|
|
11
|
+
sizeBytes;
|
|
12
|
+
maxSizeBytes;
|
|
13
|
+
constructor(url, sizeBytes, maxSizeBytes) {
|
|
14
|
+
super(`HTTP response from ${url} exceeded ${maxSizeBytes} bytes (read ${sizeBytes})`);
|
|
15
|
+
this.name = 'ResponseBodyTooLargeError';
|
|
16
|
+
this.url = url;
|
|
17
|
+
this.sizeBytes = sizeBytes;
|
|
18
|
+
this.maxSizeBytes = maxSizeBytes;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function abortReason(signal) {
|
|
22
|
+
if (signal.reason instanceof Error)
|
|
23
|
+
return signal.reason;
|
|
24
|
+
if (signal.reason !== undefined)
|
|
25
|
+
return new Error(String(signal.reason));
|
|
26
|
+
return new Error('HTTP response body read aborted');
|
|
27
|
+
}
|
|
28
|
+
function validateMaxSize(maxSizeBytes) {
|
|
29
|
+
if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) {
|
|
30
|
+
throw new RangeError('maxSizeBytes must be a non-negative safe integer');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function readResponseBodyAsBuffer({ response, url, maxSizeBytes, signal, }) {
|
|
34
|
+
validateMaxSize(maxSizeBytes);
|
|
35
|
+
const reader = response.body?.getReader();
|
|
36
|
+
if (!reader)
|
|
37
|
+
throw new MissingResponseBodyError(url);
|
|
38
|
+
let abortListener;
|
|
39
|
+
let abortPromise;
|
|
40
|
+
if (signal) {
|
|
41
|
+
abortPromise = new Promise((_resolve, reject) => {
|
|
42
|
+
abortListener = () => {
|
|
43
|
+
const reason = abortReason(signal);
|
|
44
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
45
|
+
reject(reason);
|
|
46
|
+
};
|
|
47
|
+
if (signal.aborted)
|
|
48
|
+
abortListener();
|
|
49
|
+
else
|
|
50
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
51
|
+
});
|
|
52
|
+
void abortPromise.catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let totalSize = 0;
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const result = await (abortPromise
|
|
59
|
+
? Promise.race([reader.read(), abortPromise])
|
|
60
|
+
: reader.read());
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
throw abortReason(signal);
|
|
63
|
+
if (result.done)
|
|
64
|
+
return Buffer.concat(chunks, totalSize);
|
|
65
|
+
totalSize += result.value.byteLength;
|
|
66
|
+
if (totalSize > maxSizeBytes) {
|
|
67
|
+
const error = new ResponseBodyTooLargeError(url, totalSize, maxSizeBytes);
|
|
68
|
+
await reader.cancel(error).catch(() => undefined);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
chunks.push(result.value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await reader.cancel(error).catch(() => undefined);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (signal && abortListener)
|
|
80
|
+
signal.removeEventListener('abort', abortListener);
|
|
81
|
+
reader.releaseLock();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function readResponseBody(options) {
|
|
85
|
+
return (await readResponseBodyAsBuffer(options)).toString('utf8');
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the `rel=next` target in an RFC 8288 Link header without permitting a
|
|
3
|
+
* response to redirect an authenticated pagination client to another origin.
|
|
4
|
+
*
|
|
5
|
+
* A malformed or unsafe Link header is treated as having no next page. Callers
|
|
6
|
+
* can therefore stop safely without having to distinguish an exhausted list
|
|
7
|
+
* from a provider that returned an unusable continuation.
|
|
8
|
+
*/
|
|
9
|
+
export declare function nextPageUrlFromLinkHeader(linkHeader: string | null | undefined, requestUrl: string | URL): URL | null;
|
|
10
|
+
/** Return an unambiguous next-page cursor from a safe Link header target. */
|
|
11
|
+
export declare function nextPageCursorFromLinkHeader(linkHeader: string | null | undefined, requestUrl: string | URL, cursorParameter?: string): string | null;
|
|
12
|
+
/** Validate a pagination request URL before it is used as a Link resolution base. */
|
|
13
|
+
export declare function validatePaginationRequestUrl(requestUrl: string | URL): URL;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the `rel=next` target in an RFC 8288 Link header without permitting a
|
|
3
|
+
* response to redirect an authenticated pagination client to another origin.
|
|
4
|
+
*
|
|
5
|
+
* A malformed or unsafe Link header is treated as having no next page. Callers
|
|
6
|
+
* can therefore stop safely without having to distinguish an exhausted list
|
|
7
|
+
* from a provider that returned an unusable continuation.
|
|
8
|
+
*/
|
|
9
|
+
export function nextPageUrlFromLinkHeader(linkHeader, requestUrl) {
|
|
10
|
+
const baseUrl = validatePaginationRequestUrl(requestUrl);
|
|
11
|
+
if (linkHeader == null)
|
|
12
|
+
return null;
|
|
13
|
+
const links = parseLinkHeader(linkHeader);
|
|
14
|
+
if (!links)
|
|
15
|
+
return null;
|
|
16
|
+
const nextLinks = links.filter((link) => link.relations.includes('next'));
|
|
17
|
+
if (nextLinks.length !== 1)
|
|
18
|
+
return null;
|
|
19
|
+
let nextUrl;
|
|
20
|
+
try {
|
|
21
|
+
nextUrl = new URL(nextLinks[0].target, baseUrl);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (nextUrl.origin !== baseUrl.origin ||
|
|
27
|
+
nextUrl.username !== '' ||
|
|
28
|
+
nextUrl.password !== '' ||
|
|
29
|
+
nextUrl.hash !== '') {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return nextUrl;
|
|
33
|
+
}
|
|
34
|
+
/** Return an unambiguous next-page cursor from a safe Link header target. */
|
|
35
|
+
export function nextPageCursorFromLinkHeader(linkHeader, requestUrl, cursorParameter = 'page') {
|
|
36
|
+
const nextUrl = nextPageUrlFromLinkHeader(linkHeader, requestUrl);
|
|
37
|
+
if (!nextUrl)
|
|
38
|
+
return null;
|
|
39
|
+
const values = nextUrl.searchParams.getAll(cursorParameter);
|
|
40
|
+
return values.length === 1 && values[0] !== '' ? values[0] : null;
|
|
41
|
+
}
|
|
42
|
+
/** Validate a pagination request URL before it is used as a Link resolution base. */
|
|
43
|
+
export function validatePaginationRequestUrl(requestUrl) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(requestUrl);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error('pagination request URL must be an absolute HTTP(S) URL without credentials');
|
|
50
|
+
}
|
|
51
|
+
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
52
|
+
url.username !== '' ||
|
|
53
|
+
url.password !== '' ||
|
|
54
|
+
url.hash !== '') {
|
|
55
|
+
throw new Error('pagination request URL must be an absolute HTTP(S) URL without credentials');
|
|
56
|
+
}
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
function parseLinkHeader(header) {
|
|
60
|
+
const links = [];
|
|
61
|
+
let index = 0;
|
|
62
|
+
while (index < header.length) {
|
|
63
|
+
index = skipWhitespace(header, index);
|
|
64
|
+
if (header[index] !== '<')
|
|
65
|
+
return null;
|
|
66
|
+
const targetEnd = header.indexOf('>', index + 1);
|
|
67
|
+
if (targetEnd === -1)
|
|
68
|
+
return null;
|
|
69
|
+
const target = header.slice(index + 1, targetEnd);
|
|
70
|
+
if (target === '')
|
|
71
|
+
return null;
|
|
72
|
+
index = targetEnd + 1;
|
|
73
|
+
const relations = [];
|
|
74
|
+
while (true) {
|
|
75
|
+
index = skipWhitespace(header, index);
|
|
76
|
+
if (index === header.length || header[index] === ',')
|
|
77
|
+
break;
|
|
78
|
+
if (header[index] !== ';')
|
|
79
|
+
return null;
|
|
80
|
+
index = skipWhitespace(header, index + 1);
|
|
81
|
+
const name = readToken(header, index);
|
|
82
|
+
if (!name)
|
|
83
|
+
return null;
|
|
84
|
+
index = name.end;
|
|
85
|
+
index = skipWhitespace(header, index);
|
|
86
|
+
if (header[index] !== '=')
|
|
87
|
+
return null;
|
|
88
|
+
index = skipWhitespace(header, index + 1);
|
|
89
|
+
const value = readParameterValue(header, index);
|
|
90
|
+
if (!value)
|
|
91
|
+
return null;
|
|
92
|
+
index = value.end;
|
|
93
|
+
if (name.value.toLowerCase() === 'rel') {
|
|
94
|
+
relations.push(...value.value.toLowerCase().split(/\s+/).filter(Boolean));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
links.push({ target, relations });
|
|
98
|
+
if (index === header.length)
|
|
99
|
+
return links;
|
|
100
|
+
index += 1;
|
|
101
|
+
if (index === header.length)
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
function skipWhitespace(value, index) {
|
|
107
|
+
while (value[index] === ' ' || value[index] === '\t')
|
|
108
|
+
index += 1;
|
|
109
|
+
return index;
|
|
110
|
+
}
|
|
111
|
+
function readToken(value, index) {
|
|
112
|
+
const start = index;
|
|
113
|
+
while (index < value.length && isTokenCharacter(value[index]))
|
|
114
|
+
index += 1;
|
|
115
|
+
return index === start ? null : { value: value.slice(start, index), end: index };
|
|
116
|
+
}
|
|
117
|
+
function isTokenCharacter(value) {
|
|
118
|
+
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]$/.test(value);
|
|
119
|
+
}
|
|
120
|
+
function readParameterValue(value, index) {
|
|
121
|
+
if (value[index] !== '"')
|
|
122
|
+
return readToken(value, index);
|
|
123
|
+
let parsed = '';
|
|
124
|
+
index += 1;
|
|
125
|
+
while (index < value.length) {
|
|
126
|
+
const character = value[index];
|
|
127
|
+
if (character === '"')
|
|
128
|
+
return { value: parsed, end: index + 1 };
|
|
129
|
+
if (character === '\\') {
|
|
130
|
+
const escaped = value[index + 1];
|
|
131
|
+
if (!escaped)
|
|
132
|
+
return null;
|
|
133
|
+
parsed += escaped;
|
|
134
|
+
index += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
parsed += character;
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface ReportTarget<TType extends string, TId> {
|
|
2
|
+
type: TType;
|
|
3
|
+
id: TId;
|
|
4
|
+
}
|
|
5
|
+
interface ReportDraft<TType extends string, TId, TReason extends string> {
|
|
6
|
+
target: ReportTarget<TType, TId>;
|
|
7
|
+
reason: TReason;
|
|
8
|
+
note: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface ReportInputConfig<TType extends string, TId, TReason extends string> {
|
|
11
|
+
targetTypes: readonly TType[];
|
|
12
|
+
reasons: readonly TReason[];
|
|
13
|
+
parseTargetId(value: string): TId | null;
|
|
14
|
+
maxNoteLength: number;
|
|
15
|
+
validate?(draft: ReportDraft<TType, TId, TReason>): void;
|
|
16
|
+
}
|
|
17
|
+
interface RawReportInput {
|
|
18
|
+
targetType?: unknown;
|
|
19
|
+
targetId?: unknown;
|
|
20
|
+
reason?: unknown;
|
|
21
|
+
note?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface ReportInputParser<TType extends string, TId, TReason extends string> {
|
|
24
|
+
parse(raw: RawReportInput): ReportDraft<TType, TId, TReason>;
|
|
25
|
+
readonly targetTypes: readonly TType[];
|
|
26
|
+
readonly reasons: readonly TReason[];
|
|
27
|
+
}
|
|
28
|
+
export type ReportValidationCode = 'invalid_target_type' | 'invalid_target_identifier' | 'invalid_reason' | 'invalid_note' | 'note_too_long';
|
|
29
|
+
export declare class ReportValidationError extends Error {
|
|
30
|
+
readonly code: ReportValidationCode;
|
|
31
|
+
constructor(code: ReportValidationCode, message: string);
|
|
32
|
+
}
|
|
33
|
+
export declare function createReportInputParser<TType extends string, TId, TReason extends string>(config: ReportInputConfig<TType, TId, TReason>): ReportInputParser<TType, TId, TReason>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class ReportValidationError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'ReportValidationError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function createReportInputParser(config) {
|
|
10
|
+
const targetTypes = readonlyCatalog(config.targetTypes, 'targetTypes');
|
|
11
|
+
const reasons = readonlyCatalog(config.reasons, 'reasons');
|
|
12
|
+
if (!Number.isSafeInteger(config.maxNoteLength) || config.maxNoteLength < 0)
|
|
13
|
+
throw new TypeError('maxNoteLength must be a non-negative safe integer');
|
|
14
|
+
return {
|
|
15
|
+
targetTypes,
|
|
16
|
+
reasons,
|
|
17
|
+
parse(raw) {
|
|
18
|
+
const targetType = enumValue(raw.targetType, targetTypes, 'invalid_target_type', 'target type');
|
|
19
|
+
if (typeof raw.targetId !== 'string')
|
|
20
|
+
invalid('invalid_target_identifier', 'target identifier');
|
|
21
|
+
const targetId = config.parseTargetId(raw.targetId);
|
|
22
|
+
if (targetId === null)
|
|
23
|
+
invalid('invalid_target_identifier', 'target identifier');
|
|
24
|
+
const reason = enumValue(raw.reason, reasons, 'invalid_reason', 'reason');
|
|
25
|
+
if (raw.note !== undefined && raw.note !== null && typeof raw.note !== 'string')
|
|
26
|
+
invalid('invalid_note', 'note');
|
|
27
|
+
if (typeof raw.note === 'string' && raw.note.length > config.maxNoteLength)
|
|
28
|
+
throw new ReportValidationError('note_too_long', 'Note is too long');
|
|
29
|
+
const note = typeof raw.note === 'string' ? raw.note.trim() || null : null;
|
|
30
|
+
const draft = { target: { type: targetType, id: targetId }, reason, note };
|
|
31
|
+
config.validate?.(draft);
|
|
32
|
+
return draft;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readonlyCatalog(values, name) {
|
|
37
|
+
if (values.length === 0 ||
|
|
38
|
+
values.some((value) => typeof value !== 'string' || value.length === 0))
|
|
39
|
+
throw new TypeError(`${name} must contain non-empty strings`);
|
|
40
|
+
return Object.freeze([...new Set(values)]);
|
|
41
|
+
}
|
|
42
|
+
function enumValue(value, catalog, code, name) {
|
|
43
|
+
if (typeof value !== 'string' || !catalog.includes(value))
|
|
44
|
+
invalid(code, name);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function invalid(code, name) {
|
|
48
|
+
throw new ReportValidationError(code, `Invalid ${name}`);
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Dependency-free runtime utilities for Vouchington-style applications.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -130,6 +130,11 @@
|
|
|
130
130
|
"import": "./dist/validation.mjs",
|
|
131
131
|
"default": "./dist/validation.mjs"
|
|
132
132
|
},
|
|
133
|
+
"./moderation": {
|
|
134
|
+
"types": "./dist/moderation.d.mts",
|
|
135
|
+
"import": "./dist/moderation.mjs",
|
|
136
|
+
"default": "./dist/moderation.mjs"
|
|
137
|
+
},
|
|
133
138
|
"./async": {
|
|
134
139
|
"types": "./dist/async.d.mts",
|
|
135
140
|
"import": "./dist/async.mjs",
|
|
@@ -154,6 +159,16 @@
|
|
|
154
159
|
"types": "./dist/fetch-ports.d.mts",
|
|
155
160
|
"import": "./dist/fetch-ports.mjs",
|
|
156
161
|
"default": "./dist/fetch-ports.mjs"
|
|
162
|
+
},
|
|
163
|
+
"./http-body": {
|
|
164
|
+
"types": "./dist/http-body.d.mts",
|
|
165
|
+
"import": "./dist/http-body.mjs",
|
|
166
|
+
"default": "./dist/http-body.mjs"
|
|
167
|
+
},
|
|
168
|
+
"./http-link-pagination": {
|
|
169
|
+
"types": "./dist/http-link-pagination.d.mts",
|
|
170
|
+
"import": "./dist/http-link-pagination.mjs",
|
|
171
|
+
"default": "./dist/http-link-pagination.mjs"
|
|
157
172
|
}
|
|
158
173
|
},
|
|
159
174
|
"publishConfig": {
|