@layervai/qurl 3.1.1 → 4.0.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 +259 -46
- package/dist/cjs/client-connectors.d.ts +44 -0
- package/dist/cjs/client-connectors.js +189 -0
- package/dist/cjs/client-delegated.d.ts +30 -0
- package/dist/cjs/client-delegated.js +312 -0
- package/dist/cjs/client-errors.d.ts +44 -0
- package/dist/cjs/client-errors.js +219 -0
- package/dist/cjs/client-http-types.d.ts +37 -0
- package/dist/cjs/client-http-types.js +2 -0
- package/dist/cjs/client-input-fields.d.ts +53 -0
- package/dist/cjs/client-input-fields.js +262 -0
- package/dist/cjs/client-limits.d.ts +36 -0
- package/dist/cjs/client-limits.js +61 -0
- package/dist/cjs/client-management.d.ts +12 -0
- package/dist/cjs/client-management.js +119 -0
- package/dist/cjs/client-paths.d.ts +21 -0
- package/dist/cjs/client-paths.js +143 -0
- package/dist/cjs/client-portals.d.ts +82 -0
- package/dist/cjs/client-portals.js +244 -0
- package/dist/cjs/client-resources.d.ts +29 -0
- package/dist/cjs/client-resources.js +331 -0
- package/dist/cjs/client-validation.d.ts +35 -0
- package/dist/cjs/client-validation.js +326 -0
- package/dist/cjs/client.d.ts +45 -75
- package/dist/cjs/client.js +621 -2448
- package/dist/cjs/errors.d.ts +9 -1
- package/dist/cjs/errors.js +10 -1
- package/dist/cjs/index.d.ts +4 -2
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/response-metadata.d.ts +13 -0
- package/dist/cjs/response-metadata.js +38 -0
- package/dist/cjs/share.js +2 -0
- package/dist/cjs/target-path.d.ts +2 -0
- package/dist/cjs/target-path.js +34 -0
- package/dist/cjs/types.d.ts +83 -7
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client-connectors.d.ts +44 -0
- package/dist/esm/client-connectors.js +180 -0
- package/dist/esm/client-delegated.d.ts +30 -0
- package/dist/esm/client-delegated.js +295 -0
- package/dist/esm/client-errors.d.ts +44 -0
- package/dist/esm/client-errors.js +206 -0
- package/dist/esm/client-http-types.d.ts +37 -0
- package/dist/esm/client-http-types.js +1 -0
- package/dist/esm/client-input-fields.d.ts +53 -0
- package/dist/esm/client-input-fields.js +258 -0
- package/dist/esm/client-limits.d.ts +36 -0
- package/dist/esm/client-limits.js +58 -0
- package/dist/esm/client-management.d.ts +12 -0
- package/dist/esm/client-management.js +113 -0
- package/dist/esm/client-paths.d.ts +21 -0
- package/dist/esm/client-paths.js +138 -0
- package/dist/esm/client-portals.d.ts +82 -0
- package/dist/esm/client-portals.js +236 -0
- package/dist/esm/client-resources.d.ts +29 -0
- package/dist/esm/client-resources.js +323 -0
- package/dist/esm/client-validation.d.ts +35 -0
- package/dist/esm/client-validation.js +302 -0
- package/dist/esm/client.d.ts +45 -76
- package/dist/esm/client.js +318 -2147
- package/dist/esm/crid.d.ts +0 -1
- package/dist/esm/crid.js +0 -1
- package/dist/esm/errors.d.ts +9 -2
- package/dist/esm/errors.js +9 -1
- package/dist/esm/index.d.ts +4 -3
- package/dist/esm/index.js +2 -2
- package/dist/esm/internal.d.ts +0 -1
- package/dist/esm/internal.js +0 -1
- package/dist/esm/node/agent-runtime.d.ts +0 -1
- package/dist/esm/node/agent-runtime.js +0 -1
- package/dist/esm/node/agent-state.d.ts +0 -1
- package/dist/esm/node/agent-state.js +0 -1
- package/dist/esm/node/agent-transport.d.ts +0 -1
- package/dist/esm/node/agent-transport.js +0 -1
- package/dist/esm/node/deployment.d.ts +0 -1
- package/dist/esm/node/deployment.js +0 -1
- package/dist/esm/node/file-agent-state.d.ts +0 -1
- package/dist/esm/node/file-agent-state.js +0 -1
- package/dist/esm/node/native-udp.d.ts +0 -1
- package/dist/esm/node/native-udp.js +0 -1
- package/dist/esm/node/nhp-wire.d.ts +0 -1
- package/dist/esm/node/nhp-wire.js +0 -1
- package/dist/esm/node/portal-opener.d.ts +0 -1
- package/dist/esm/node/portal-opener.js +0 -1
- package/dist/esm/node/provider.d.ts +0 -1
- package/dist/esm/node/provider.js +0 -1
- package/dist/esm/node/qv2.d.ts +0 -1
- package/dist/esm/node/qv2.js +0 -1
- package/dist/esm/node/relay.d.ts +0 -1
- package/dist/esm/node/relay.js +0 -1
- package/dist/esm/node/sealed-agent-state.d.ts +0 -1
- package/dist/esm/node/sealed-agent-state.js +0 -1
- package/dist/esm/node/strict-json.d.ts +0 -1
- package/dist/esm/node/strict-json.js +0 -1
- package/dist/esm/node.d.ts +0 -1
- package/dist/esm/node.js +0 -1
- package/dist/esm/response-metadata.d.ts +13 -0
- package/dist/esm/response-metadata.js +33 -0
- package/dist/esm/share.d.ts +0 -1
- package/dist/esm/share.js +2 -1
- package/dist/esm/target-path.d.ts +2 -0
- package/dist/esm/target-path.js +30 -0
- package/dist/esm/types.d.ts +83 -8
- package/dist/esm/types.js +0 -1
- package/dist/esm/version.d.ts +1 -2
- package/dist/esm/version.js +1 -2
- package/package.json +9 -7
- package/dist/esm/client.d.ts.map +0 -1
- package/dist/esm/client.js.map +0 -1
- package/dist/esm/crid.d.ts.map +0 -1
- package/dist/esm/crid.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal.d.ts.map +0 -1
- package/dist/esm/internal.js.map +0 -1
- package/dist/esm/node/agent-runtime.d.ts.map +0 -1
- package/dist/esm/node/agent-runtime.js.map +0 -1
- package/dist/esm/node/agent-state.d.ts.map +0 -1
- package/dist/esm/node/agent-state.js.map +0 -1
- package/dist/esm/node/agent-transport.d.ts.map +0 -1
- package/dist/esm/node/agent-transport.js.map +0 -1
- package/dist/esm/node/deployment.d.ts.map +0 -1
- package/dist/esm/node/deployment.js.map +0 -1
- package/dist/esm/node/file-agent-state.d.ts.map +0 -1
- package/dist/esm/node/file-agent-state.js.map +0 -1
- package/dist/esm/node/native-udp.d.ts.map +0 -1
- package/dist/esm/node/native-udp.js.map +0 -1
- package/dist/esm/node/nhp-wire.d.ts.map +0 -1
- package/dist/esm/node/nhp-wire.js.map +0 -1
- package/dist/esm/node/portal-opener.d.ts.map +0 -1
- package/dist/esm/node/portal-opener.js.map +0 -1
- package/dist/esm/node/provider.d.ts.map +0 -1
- package/dist/esm/node/provider.js.map +0 -1
- package/dist/esm/node/qv2.d.ts.map +0 -1
- package/dist/esm/node/qv2.js.map +0 -1
- package/dist/esm/node/relay.d.ts.map +0 -1
- package/dist/esm/node/relay.js.map +0 -1
- package/dist/esm/node/sealed-agent-state.d.ts.map +0 -1
- package/dist/esm/node/sealed-agent-state.js.map +0 -1
- package/dist/esm/node/strict-json.d.ts.map +0 -1
- package/dist/esm/node/strict-json.js.map +0 -1
- package/dist/esm/node.d.ts.map +0 -1
- package/dist/esm/node.js.map +0 -1
- package/dist/esm/share.d.ts.map +0 -1
- package/dist/esm/share.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/esm/version.d.ts.map +0 -1
- package/dist/esm/version.js.map +0 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { requireCanonicalTargetPath } from "./target-path.js";
|
|
2
|
+
import { ValidationError } from "./errors.js";
|
|
3
|
+
import { TEXT_ENCODER, clientValidationError } from "./client-errors.js";
|
|
4
|
+
import { describeShape, requireBooleanIfPresent, requireMaxLength, requireMaxSessionsInRange, requireNoUnknownFields, requireNonEmptyIfPresent, requireObjectInput, requireResourceNameIfPresent, requireStringArrayElements, requireValidAccessPolicy, requireValidTags, requireValidTargetUrl, } from "./client-validation.js";
|
|
5
|
+
import { CREATE_FIELD_KEYS } from "./client-input-fields.js";
|
|
6
|
+
import { MAX_ALIAS, MAX_ALLOWED_DEVICE_KEYS, MAX_CONSUMER_CONTRACT_ID_BYTES, MAX_CUSTOM_DOMAIN, MAX_DESCRIPTION, MAX_LABEL, MAX_SLUG, } from "./client-limits.js";
|
|
7
|
+
export function validateCreateInput(input) {
|
|
8
|
+
// Untyped-JS guard: surface as ValidationError instead of raw TypeError.
|
|
9
|
+
if (typeof input !== "object" || input === null) {
|
|
10
|
+
throw clientValidationError(`create: input must be an object (got ${input === null ? "null" : typeof input})`);
|
|
11
|
+
}
|
|
12
|
+
// Collect-all: run every validator and aggregate the messages so a
|
|
13
|
+
// caller fixing multiple bad fields sees them all at once instead
|
|
14
|
+
// of fix-re-run-repeat. Matches the documented batchCreate UX
|
|
15
|
+
// ("All failures are collected into a single ValidationError")
|
|
16
|
+
// and mirrors the requireValidTags collect-all pattern.
|
|
17
|
+
const errors = [];
|
|
18
|
+
const collect = (fn) => {
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err instanceof ValidationError) {
|
|
24
|
+
errors.push(err.detail);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
collect(() => requireNoUnknownFields(input, CREATE_FIELD_KEYS, "create"));
|
|
31
|
+
const resourceType = input.type;
|
|
32
|
+
if (resourceType !== undefined && typeof resourceType !== "string") {
|
|
33
|
+
errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
|
|
34
|
+
}
|
|
35
|
+
collect(() => {
|
|
36
|
+
if (input.target_url === undefined) {
|
|
37
|
+
if (resourceType === undefined || resourceType === "url") {
|
|
38
|
+
throw clientValidationError("target_url: is required for url qURLs");
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
requireValidTargetUrl(input.target_url);
|
|
43
|
+
});
|
|
44
|
+
collect(() => requireResourceNameIfPresent(input.name));
|
|
45
|
+
collect(() => requireMaxLength(input.label, "label", MAX_LABEL));
|
|
46
|
+
collect(() => requireNonEmptyIfPresent(input.label, "label"));
|
|
47
|
+
collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
|
|
48
|
+
collect(() => requireNonEmptyIfPresent(input.custom_domain, "custom_domain"));
|
|
49
|
+
collect(() => requireBooleanIfPresent(input.private, "private"));
|
|
50
|
+
collect(() => requireBooleanIfPresent(input.one_time_use, "one_time_use"));
|
|
51
|
+
collect(() => requireMaxSessionsInRange(input.max_sessions));
|
|
52
|
+
collect(() => requireNonEmptyIfPresent(input.expires_in, "expires_in"));
|
|
53
|
+
collect(() => requireNonEmptyIfPresent(input.session_duration, "session_duration"));
|
|
54
|
+
collect(() => requireValidAccessPolicy(input.access_policy));
|
|
55
|
+
if (errors.length > 0) {
|
|
56
|
+
// Inner `; ` is contractual (paired with batchCreate's outer ` | `).
|
|
57
|
+
// Future field-level validators must not aggregate via `; ` internally.
|
|
58
|
+
throw clientValidationError(errors.join("; "));
|
|
59
|
+
}
|
|
60
|
+
// Intentional omission: duration *grammar* (`expires_in`,
|
|
61
|
+
// `session_duration`) is server-authoritative. Empty strings are
|
|
62
|
+
// rejected up front (see collect() calls above) but unit/whitespace
|
|
63
|
+
// edge cases ("24hh", "1w 3d") surface as a clean server 400 — a
|
|
64
|
+
// client-side regex would just create a drift surface against the
|
|
65
|
+
// server's rego/duration parser.
|
|
66
|
+
//
|
|
67
|
+
// `access_policy` contents (CIDRs, ISO-3166 codes, regex patterns)
|
|
68
|
+
// are pass-through for the same reason. The shape-guard above only
|
|
69
|
+
// catches structural mistakes (string-instead-of-array on list fields)
|
|
70
|
+
// an untyped-JS caller is likely to make.
|
|
71
|
+
}
|
|
72
|
+
export function validateQurlTokenOptions(input) {
|
|
73
|
+
if (input === undefined)
|
|
74
|
+
return;
|
|
75
|
+
requireMaxLength(input.label, "label", MAX_LABEL);
|
|
76
|
+
requireNonEmptyIfPresent(input.label, "label");
|
|
77
|
+
requireNonEmptyIfPresent(input.expires_in, "expires_in");
|
|
78
|
+
requireNonEmptyIfPresent(input.session_duration, "session_duration");
|
|
79
|
+
requireCanonicalTargetPath(input.target_path, "target_path");
|
|
80
|
+
requireBooleanIfPresent(input.one_time_use, "one_time_use");
|
|
81
|
+
requireMaxSessionsInRange(input.max_sessions);
|
|
82
|
+
requireValidAccessPolicy(input.access_policy);
|
|
83
|
+
}
|
|
84
|
+
export function validateResourceQurlUpdateInput(input) {
|
|
85
|
+
requireMaxLength(input.label, "label", MAX_LABEL);
|
|
86
|
+
requireNonEmptyIfPresent(input.label, "label");
|
|
87
|
+
requireNonEmptyIfPresent(input.extend_by, "extend_by");
|
|
88
|
+
requireNonEmptyIfPresent(input.expires_at, "expires_at");
|
|
89
|
+
requireNonEmptyIfPresent(input.session_duration, "session_duration");
|
|
90
|
+
requireMaxSessionsInRange(input.max_sessions);
|
|
91
|
+
requireValidAccessPolicy(input.access_policy);
|
|
92
|
+
if (input.extend_by !== undefined && input.expires_at !== undefined) {
|
|
93
|
+
throw clientValidationError("updateResourceQurl: `extend_by` and `expires_at` are mutually exclusive — provide at most one");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function validateResourceWriteFields(input, options = {}) {
|
|
97
|
+
const errors = [];
|
|
98
|
+
const collect = (fn) => {
|
|
99
|
+
try {
|
|
100
|
+
fn();
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof ValidationError) {
|
|
104
|
+
errors.push(err.detail);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
collect(() => requireResourceNameIfPresent(input.name));
|
|
111
|
+
collect(() => {
|
|
112
|
+
if (input.private !== undefined && typeof input.private !== "boolean") {
|
|
113
|
+
throw clientValidationError("private: must be a boolean");
|
|
114
|
+
}
|
|
115
|
+
if (input.consumer_access !== undefined) {
|
|
116
|
+
const policy = input.consumer_access;
|
|
117
|
+
requireObjectInput(policy, "consumer_access");
|
|
118
|
+
const p = policy;
|
|
119
|
+
requireNoUnknownFields(p, ["mode", "version", "contract_id", "payment"], "consumer_access");
|
|
120
|
+
if (!["keys", "invite_only", "email_capture", "review", "contract"].includes(p.mode) ||
|
|
121
|
+
!Number.isSafeInteger(p.version) ||
|
|
122
|
+
p.version < 1) {
|
|
123
|
+
throw clientValidationError("consumer_access: invalid mode or version");
|
|
124
|
+
}
|
|
125
|
+
if (p.mode === "contract") {
|
|
126
|
+
if (typeof p.contract_id !== "string" ||
|
|
127
|
+
!p.contract_id.trim() ||
|
|
128
|
+
TEXT_ENCODER.encode(p.contract_id).length > MAX_CONSUMER_CONTRACT_ID_BYTES) {
|
|
129
|
+
throw clientValidationError("consumer_access: contract mode requires a bounded contract_id");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (p.contract_id !== undefined) {
|
|
133
|
+
throw clientValidationError("consumer_access: contract_id requires contract mode");
|
|
134
|
+
}
|
|
135
|
+
if (p.payment !== undefined) {
|
|
136
|
+
requireObjectInput(p.payment, "consumer_access.payment");
|
|
137
|
+
const payment = p.payment;
|
|
138
|
+
requireNoUnknownFields(payment, ["account_id", "price_id", "access_seconds"], "consumer_access.payment");
|
|
139
|
+
if (p.mode !== "contract" ||
|
|
140
|
+
typeof payment.account_id !== "string" ||
|
|
141
|
+
!/^acct_[A-Za-z0-9]{1,128}$/.test(payment.account_id) ||
|
|
142
|
+
typeof payment.price_id !== "string" ||
|
|
143
|
+
!/^price_[A-Za-z0-9]{1,128}$/.test(payment.price_id) ||
|
|
144
|
+
!Number.isSafeInteger(payment.access_seconds) ||
|
|
145
|
+
Number(payment.access_seconds) < 0 ||
|
|
146
|
+
Number(payment.access_seconds) > 315360000)
|
|
147
|
+
throw clientValidationError("consumer_access.payment: invalid Stripe contract");
|
|
148
|
+
}
|
|
149
|
+
if (input.private === false)
|
|
150
|
+
throw clientValidationError("consumer_access: requires a private resource");
|
|
151
|
+
if (options.requireUrlTarget) {
|
|
152
|
+
if (p.version !== 1 || input.private !== true)
|
|
153
|
+
throw clientValidationError("consumer_access: creation requires private true and version one");
|
|
154
|
+
}
|
|
155
|
+
else if (options.allowAliasClear) {
|
|
156
|
+
const expected = input.expected_consumer_access_version;
|
|
157
|
+
if (!Number.isSafeInteger(expected) ||
|
|
158
|
+
expected < 0 ||
|
|
159
|
+
p.version !== expected + 1 ||
|
|
160
|
+
Object.keys(input).some((key) => key !== "consumer_access" && key !== "expected_consumer_access_version")) {
|
|
161
|
+
throw clientValidationError("consumer_access: update requires its expected version and no metadata changes");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (input.expected_consumer_access_version !== undefined) {
|
|
166
|
+
throw clientValidationError("expected_consumer_access_version: requires consumer_access");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
collect(() => {
|
|
170
|
+
const fields = ["allowed_device_keys_add", "allowed_device_keys_remove"];
|
|
171
|
+
for (const field of fields) {
|
|
172
|
+
const keys = input[field];
|
|
173
|
+
if (keys === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
requireStringArrayElements(keys, field, "updateResource");
|
|
176
|
+
if (keys.length > MAX_ALLOWED_DEVICE_KEYS || new Set(keys).size !== keys.length) {
|
|
177
|
+
throw clientValidationError(`${field}: must contain at most ${MAX_ALLOWED_DEVICE_KEYS} unique keys`);
|
|
178
|
+
}
|
|
179
|
+
for (const key of keys) {
|
|
180
|
+
let canonical = false;
|
|
181
|
+
try {
|
|
182
|
+
canonical = key.length === 44 && atob(key).length === 32 && btoa(atob(key)) === key;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* Invalid base64 is rejected below. */
|
|
186
|
+
}
|
|
187
|
+
if (!canonical) {
|
|
188
|
+
throw clientValidationError(`${field}: keys must be canonical padded base64 of 32 bytes`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (input.custom_domain !== undefined) {
|
|
192
|
+
throw clientValidationError(`${field}: update separately from custom_domain`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const added = new Set(input.allowed_device_keys_add);
|
|
196
|
+
if (input.allowed_device_keys_remove?.some((key) => added.has(key))) {
|
|
197
|
+
throw clientValidationError("allowed device key additions and removals must not overlap");
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
const resourceType = input.type;
|
|
201
|
+
if (resourceType !== undefined && typeof resourceType !== "string") {
|
|
202
|
+
errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
|
|
203
|
+
}
|
|
204
|
+
collect(() => {
|
|
205
|
+
if (input.target_url === undefined) {
|
|
206
|
+
if (options.requireUrlTarget && (resourceType === undefined || resourceType === "url")) {
|
|
207
|
+
throw clientValidationError("target_url: is required for url resources");
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
requireValidTargetUrl(input.target_url);
|
|
212
|
+
});
|
|
213
|
+
collect(() => requireMaxLength(input.description, "description", MAX_DESCRIPTION));
|
|
214
|
+
collect(() => requireMaxLength(input.custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN));
|
|
215
|
+
collect(() => {
|
|
216
|
+
if (options.allowCustomDomainClear && input.custom_domain === "")
|
|
217
|
+
return;
|
|
218
|
+
requireNonEmptyIfPresent(input.custom_domain, "custom_domain");
|
|
219
|
+
});
|
|
220
|
+
collect(() => requireValidTags(input.tags));
|
|
221
|
+
collect(() => {
|
|
222
|
+
if (input.alias !== undefined) {
|
|
223
|
+
if (input.alias === null) {
|
|
224
|
+
if (!options.allowAliasClear) {
|
|
225
|
+
throw clientValidationError("alias: must be a string (got null)");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
requireMaxLength(input.alias, "alias", MAX_ALIAS);
|
|
230
|
+
requireNonEmptyIfPresent(input.alias, "alias");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
collect(() => {
|
|
235
|
+
if (input.slug === undefined)
|
|
236
|
+
return;
|
|
237
|
+
requireMaxLength(input.slug, "slug", MAX_SLUG);
|
|
238
|
+
requireNonEmptyIfPresent(input.slug, "slug");
|
|
239
|
+
});
|
|
240
|
+
collect(() => {
|
|
241
|
+
if (options.validateFindOrCreate &&
|
|
242
|
+
input.find_or_create !== undefined &&
|
|
243
|
+
typeof input.find_or_create !== "boolean") {
|
|
244
|
+
throw clientValidationError(`find_or_create: must be a boolean (got ${describeShape(input.find_or_create)})`);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
collect(() => {
|
|
248
|
+
if (options.validatePreserveHost &&
|
|
249
|
+
input.preserve_host !== undefined &&
|
|
250
|
+
typeof input.preserve_host !== "boolean") {
|
|
251
|
+
throw clientValidationError(`preserve_host: must be a boolean (got ${describeShape(input.preserve_host)})`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
if (errors.length > 0) {
|
|
255
|
+
throw clientValidationError(errors.join("; "));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Per-entry shape guard for {@link BatchItemResult}. Verifies every
|
|
260
|
+
* non-optional field on the branch of the discriminated union selected
|
|
261
|
+
* by the `success` discriminant. Protects consumers who narrow on
|
|
262
|
+
* `success` and then access `resource_id` / `qurl_link` / `error` as
|
|
263
|
+
* guaranteed-present — if the API ever omits one, we trip the guard
|
|
264
|
+
* rather than returning `undefined` where the type says `string`.
|
|
265
|
+
*
|
|
266
|
+
* Returns `null` when the entry is valid, or a human-readable reason
|
|
267
|
+
* string describing the *specific* field that's missing or wrong. The
|
|
268
|
+
* reason never echoes the entry's values — only field names and shape
|
|
269
|
+
* descriptors — so the caller can safely surface it in error messages
|
|
270
|
+
* that may end up in observability pipelines. Contrast with the outer
|
|
271
|
+
* guard's intentional silence about response-body content for the
|
|
272
|
+
* same info-leak reason.
|
|
273
|
+
*/
|
|
274
|
+
export function batchItemResultValidationReason(entry) {
|
|
275
|
+
if (!entry || typeof entry !== "object")
|
|
276
|
+
return "not an object";
|
|
277
|
+
const e = entry;
|
|
278
|
+
// `Number.isInteger` rejects NaN, Infinity, and floats; combined
|
|
279
|
+
// with `>= 0`, the check rules out pathological proxy bodies that
|
|
280
|
+
// would attribute to a useless index. `index` is informational, so
|
|
281
|
+
// this isn't a correctness hazard, but tightening the constraint
|
|
282
|
+
// matches the same posture as the count integer guards above.
|
|
283
|
+
if (!Number.isInteger(e.index) || e.index < 0) {
|
|
284
|
+
return "missing/invalid 'index' (expected non-negative integer)";
|
|
285
|
+
}
|
|
286
|
+
if (e.success === true) {
|
|
287
|
+
if (typeof e.resource_id !== "string") {
|
|
288
|
+
return "success-branch missing required field 'resource_id' (expected string)";
|
|
289
|
+
}
|
|
290
|
+
if (typeof e.qurl_link !== "string") {
|
|
291
|
+
return "success-branch missing required field 'qurl_link' (expected string)";
|
|
292
|
+
}
|
|
293
|
+
if (typeof e.qurl_site !== "string") {
|
|
294
|
+
return "success-branch missing required field 'qurl_site' (expected string)";
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
if (e.success === false) {
|
|
299
|
+
if (!e.error || typeof e.error !== "object") {
|
|
300
|
+
return "failure-branch missing required field 'error' (expected object)";
|
|
301
|
+
}
|
|
302
|
+
const err = e.error;
|
|
303
|
+
if (typeof err.code !== "string") {
|
|
304
|
+
return "failure-branch missing required field 'error.code' (expected string)";
|
|
305
|
+
}
|
|
306
|
+
if (typeof err.message !== "string") {
|
|
307
|
+
return "failure-branch missing required field 'error.message' (expected string)";
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
return "missing/invalid 'success' discriminant (expected boolean)";
|
|
312
|
+
}
|
|
313
|
+
export function sameConsumerPolicy(actual, expected, foundExisting) {
|
|
314
|
+
return (actual?.mode === expected.mode &&
|
|
315
|
+
(actual?.version === expected.version ||
|
|
316
|
+
(foundExisting &&
|
|
317
|
+
Number.isSafeInteger(actual?.version) &&
|
|
318
|
+
Number(actual?.version) > expected.version)) &&
|
|
319
|
+
actual?.contract_id === expected.contract_id &&
|
|
320
|
+
actual?.payment?.account_id === expected.payment?.account_id &&
|
|
321
|
+
actual?.payment?.price_id === expected.payment?.price_id &&
|
|
322
|
+
actual?.payment?.access_seconds === expected.payment?.access_seconds);
|
|
323
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AccessPolicy } from "./types.js";
|
|
2
|
+
export declare function requireMaxLength(value: string | undefined, field: string, max: number): void;
|
|
3
|
+
export declare function requireMaxUtf8Bytes(value: string | undefined, field: string, max: number): void;
|
|
4
|
+
/** Mirror qurl-service ValidatePreviewName: 100 code points; no Cc, Cf, U+2028/9 or padding. */
|
|
5
|
+
export declare function requireResourceNameIfPresent(value: unknown, field?: string): void;
|
|
6
|
+
export declare function requireNonEmptyIfPresent(value: unknown, field: string): void;
|
|
7
|
+
export declare function requireMaxSessionsInRange(value: number | undefined, field?: string): void;
|
|
8
|
+
export declare function requireBooleanIfPresent(value: unknown, field: string): void;
|
|
9
|
+
export declare function requireListLimitInRange(value: unknown, methodName: string): void;
|
|
10
|
+
export declare function containsControlCharacter(value: string): boolean;
|
|
11
|
+
/** Match the API's tag grammar, which permits embedded spaces; values stay in JSON bodies. */
|
|
12
|
+
export declare function requireValidTags(tags: string[] | null | undefined): void;
|
|
13
|
+
export declare function describeShape(value: unknown): string;
|
|
14
|
+
export declare function requireValidAccessPolicy(policy: AccessPolicy | null | undefined): void;
|
|
15
|
+
export declare function requireValidHttpUrl(value: unknown, field: string): void;
|
|
16
|
+
export declare function requireValidTargetUrl(target_url: unknown): void;
|
|
17
|
+
export declare function requireObjectInput(input: unknown, method: string): asserts input is Record<string, unknown>;
|
|
18
|
+
export declare function requireNonEmptyStringField(input: Record<string, unknown>, field: string, method: string): void;
|
|
19
|
+
export declare function requireNonEmptyArrayField(input: Record<string, unknown>, field: string, method: string): void;
|
|
20
|
+
export declare function requireStringArrayElements(value: unknown, field: string, method: string): asserts value is string[];
|
|
21
|
+
export declare function requireAtLeastOneField(input: Record<string, unknown>, fields: readonly string[], method: string): void;
|
|
22
|
+
export declare function requireNoUnknownFields(input: Record<string, unknown>, fields: readonly string[], method: string): void;
|
|
23
|
+
export declare function normalizePatchFields(input: Record<string, unknown>, fields: readonly string[], options?: {
|
|
24
|
+
preserveNullFields?: readonly string[];
|
|
25
|
+
}): Record<string, unknown>;
|
|
26
|
+
export declare function requireConsumerDecision(id: string, version: number, allowNew?: boolean): void;
|
|
27
|
+
/**
|
|
28
|
+
* Portal-verb target URL validation (qurl-go `ProtectURL` parity). On top of
|
|
29
|
+
* the shared scheme/length check, the URL must carry a host and must parse,
|
|
30
|
+
* and embedded credentials (userinfo) are rejected. The added checks never
|
|
31
|
+
* echo the URL — a userinfo URL contains credentials that must stay out of
|
|
32
|
+
* logs and error messages. The REST-shaped compatibility layer keeps its
|
|
33
|
+
* original prefix-only check so its accepted inputs don't change.
|
|
34
|
+
*/
|
|
35
|
+
export declare function requirePortalTargetUrl(targetUrl: unknown): void;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { TEXT_ENCODER, clientValidationError } from "./client-errors.js";
|
|
2
|
+
import { MAX_LIST_LIMIT, MAX_MAX_SESSIONS, MAX_RESOURCE_NAME, MAX_TAGS, MAX_TAG_LENGTH, MAX_TARGET_URL, } from "./client-limits.js";
|
|
3
|
+
// CreateQurlRequest.target_url pattern is loose (just a URI) but
|
|
4
|
+
// UpdateQurlRequest.tags pattern is specific — enforce it here.
|
|
5
|
+
const TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/;
|
|
6
|
+
export function requireMaxLength(value, field, max) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return;
|
|
9
|
+
// Untyped-JS safety: a non-string `value` would silently pass since
|
|
10
|
+
// `(42).length === undefined > max` is `false` and the bad value would
|
|
11
|
+
// sail through to the wire. Match the typeof guard pattern used by
|
|
12
|
+
// requireValidTags / requireValidTargetUrl.
|
|
13
|
+
if (typeof value !== "string") {
|
|
14
|
+
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
15
|
+
}
|
|
16
|
+
if (value.length > max) {
|
|
17
|
+
throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function requireMaxUtf8Bytes(value, field, max) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return;
|
|
23
|
+
if (typeof value !== "string") {
|
|
24
|
+
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
25
|
+
}
|
|
26
|
+
const bytes = TEXT_ENCODER.encode(value).byteLength;
|
|
27
|
+
if (bytes > max) {
|
|
28
|
+
throw clientValidationError(`${field}: must be ${max} UTF-8 bytes or fewer (got ${bytes})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Mirror qurl-service ValidatePreviewName: 100 code points; no Cc, Cf, U+2028/9 or padding. */
|
|
32
|
+
export function requireResourceNameIfPresent(value, field = "name") {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return;
|
|
35
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
36
|
+
throw clientValidationError(`${field}: must be a non-empty string`);
|
|
37
|
+
}
|
|
38
|
+
if ([...value].length > MAX_RESOURCE_NAME) {
|
|
39
|
+
throw clientValidationError(`${field}: must be ${MAX_RESOURCE_NAME} characters or fewer`);
|
|
40
|
+
}
|
|
41
|
+
if (value.trim() !== value || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(value)) {
|
|
42
|
+
throw clientValidationError(`${field}: must be one line without hidden characters or surrounding whitespace`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function requireNonEmptyIfPresent(value, field) {
|
|
46
|
+
// Reject `""` so uninitialized form state doesn't reach the wire.
|
|
47
|
+
if (value === "") {
|
|
48
|
+
throw clientValidationError(`${field}: must not be an empty string`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function requireMaxSessionsInRange(value, field = "max_sessions") {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return;
|
|
54
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_MAX_SESSIONS) {
|
|
55
|
+
throw clientValidationError(`${field}: must be an integer between 0 and ${MAX_MAX_SESSIONS} (got ${value})`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function requireBooleanIfPresent(value, field) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return;
|
|
61
|
+
if (typeof value !== "boolean") {
|
|
62
|
+
throw clientValidationError(`${field}: must be a boolean (got ${describeShape(value)})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function requireListLimitInRange(value, methodName) {
|
|
66
|
+
if (value === undefined || value === null)
|
|
67
|
+
return;
|
|
68
|
+
// All current paginated OpenAPI query params share 1..100. If a future
|
|
69
|
+
// endpoint diverges, add a per-endpoint bound rather than changing this
|
|
70
|
+
// shared guard silently.
|
|
71
|
+
if (typeof value !== "number" ||
|
|
72
|
+
!Number.isInteger(value) ||
|
|
73
|
+
value < 1 ||
|
|
74
|
+
value > MAX_LIST_LIMIT) {
|
|
75
|
+
const rendered = typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
76
|
+
throw clientValidationError(`${methodName}: limit: must be an integer between 1 and ${MAX_LIST_LIMIT} (got ${rendered})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function containsControlCharacter(value) {
|
|
80
|
+
return Array.from(value).some((character) => {
|
|
81
|
+
const codePoint = character.codePointAt(0);
|
|
82
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Match the API's tag grammar, which permits embedded spaces; values stay in JSON bodies. */
|
|
86
|
+
export function requireValidTags(tags) {
|
|
87
|
+
// null tolerance for untyped-JS callers — treated as "don't touch."
|
|
88
|
+
// Duplicates pass through to the server (authoritative on dedupe policy).
|
|
89
|
+
if (tags === undefined || tags === null)
|
|
90
|
+
return;
|
|
91
|
+
if (!Array.isArray(tags)) {
|
|
92
|
+
throw clientValidationError(`tags: must be an array of strings (got ${typeof tags})`);
|
|
93
|
+
}
|
|
94
|
+
if (tags.length > MAX_TAGS) {
|
|
95
|
+
throw clientValidationError(`tags: max ${MAX_TAGS} items allowed (got ${tags.length})`);
|
|
96
|
+
}
|
|
97
|
+
const errors = [];
|
|
98
|
+
for (let i = 0; i < tags.length; i++) {
|
|
99
|
+
const tag = tags[i];
|
|
100
|
+
// Per-element type guard for untyped-JS callers. Without this, a
|
|
101
|
+
// non-string element would silently pass (`.length` on a number is
|
|
102
|
+
// `undefined`; `TAG_PATTERN.test(42)` coerces to `"42"`) or TypeError
|
|
103
|
+
// (`null.length`).
|
|
104
|
+
if (typeof tag !== "string") {
|
|
105
|
+
errors.push(`tags[${i}]: must be a string (got ${tag === null ? "null" : typeof tag})`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (tag.length < 1 || tag.length > MAX_TAG_LENGTH) {
|
|
109
|
+
errors.push(`tags[${i}]: must be 1-${MAX_TAG_LENGTH} characters (got ${tag.length})`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!TAG_PATTERN.test(tag)) {
|
|
113
|
+
errors.push(`tags[${i}]: must start with an alphanumeric and contain only letters, numbers, spaces, underscores, or hyphens`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (errors.length > 0) {
|
|
117
|
+
throw clientValidationError(errors.join("; "));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function describeShape(value) {
|
|
121
|
+
if (value === null)
|
|
122
|
+
return "null";
|
|
123
|
+
if (Array.isArray(value))
|
|
124
|
+
return "array";
|
|
125
|
+
return typeof value;
|
|
126
|
+
}
|
|
127
|
+
const ACCESS_POLICY_LIST_FIELDS = [
|
|
128
|
+
"ip_allowlist",
|
|
129
|
+
"ip_denylist",
|
|
130
|
+
"geo_allowlist",
|
|
131
|
+
"geo_denylist",
|
|
132
|
+
];
|
|
133
|
+
const ACCESS_POLICY_STRING_FIELDS = [
|
|
134
|
+
"user_agent_allow_regex",
|
|
135
|
+
"user_agent_deny_regex",
|
|
136
|
+
];
|
|
137
|
+
const AI_AGENT_POLICY_LIST_FIELDS = [
|
|
138
|
+
"deny_categories",
|
|
139
|
+
"allow_categories",
|
|
140
|
+
];
|
|
141
|
+
export function requireValidAccessPolicy(policy) {
|
|
142
|
+
// null/undefined → "don't touch". Server is authoritative on CIDR /
|
|
143
|
+
// ISO-3166 / regex grammar; this guard catches only the shape errors
|
|
144
|
+
// an untyped-JS caller is likely to make (e.g. `geo_allowlist: "US"`
|
|
145
|
+
// instead of `["US"]`) so they get a structured ValidationError
|
|
146
|
+
// instead of a server 400 they have to debug.
|
|
147
|
+
if (policy === undefined || policy === null)
|
|
148
|
+
return;
|
|
149
|
+
if (typeof policy !== "object" || Array.isArray(policy)) {
|
|
150
|
+
throw clientValidationError(`access_policy: must be an object (got ${describeShape(policy)})`);
|
|
151
|
+
}
|
|
152
|
+
for (const field of ACCESS_POLICY_LIST_FIELDS) {
|
|
153
|
+
const value = policy[field];
|
|
154
|
+
if (value === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
if (!Array.isArray(value)) {
|
|
157
|
+
throw clientValidationError(`access_policy.${field}: must be an array (got ${describeShape(value)})`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const field of ACCESS_POLICY_STRING_FIELDS) {
|
|
161
|
+
const value = policy[field];
|
|
162
|
+
if (value === undefined)
|
|
163
|
+
continue;
|
|
164
|
+
if (typeof value !== "string") {
|
|
165
|
+
throw clientValidationError(`access_policy.${field}: must be a string (got ${describeShape(value)})`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const aip = policy.ai_agent_policy;
|
|
169
|
+
if (aip !== undefined) {
|
|
170
|
+
if (aip === null || typeof aip !== "object" || Array.isArray(aip)) {
|
|
171
|
+
throw clientValidationError(`access_policy.ai_agent_policy: must be an object (got ${describeShape(aip)})`);
|
|
172
|
+
}
|
|
173
|
+
if (aip.block_all !== undefined && typeof aip.block_all !== "boolean") {
|
|
174
|
+
throw clientValidationError(`access_policy.ai_agent_policy.block_all: must be a boolean (got ${describeShape(aip.block_all)})`);
|
|
175
|
+
}
|
|
176
|
+
for (const field of AI_AGENT_POLICY_LIST_FIELDS) {
|
|
177
|
+
const value = aip[field];
|
|
178
|
+
if (value === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
if (!Array.isArray(value)) {
|
|
181
|
+
throw clientValidationError(`access_policy.ai_agent_policy.${field}: must be an array (got ${describeShape(value)})`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// `format: uri` in the OpenAPI spec allows schemes the SDK doesn't
|
|
187
|
+
// usefully support (`ftp://`, `file://`, `javascript:`, …). This is a
|
|
188
|
+
// cheap client-side sanity check — the server is still the
|
|
189
|
+
// authoritative validator (e.g. it rejects localhost, cloud metadata,
|
|
190
|
+
// and private-range hosts; the SDK doesn't need to duplicate that).
|
|
191
|
+
const ALLOWED_URL_SCHEMES = ["http://", "https://"];
|
|
192
|
+
export function requireValidHttpUrl(value, field) {
|
|
193
|
+
// Three checks in priority order: type guard, scheme, length.
|
|
194
|
+
// Stop at the first failure so a non-string URL doesn't
|
|
195
|
+
// produce both "must be a string" AND "must be ≤ 2048 characters"
|
|
196
|
+
// (the length check on a non-string would also typeof-fail and
|
|
197
|
+
// duplicate the type message). The collect-all loop in
|
|
198
|
+
// validateCreateInput runs each FIELD's validator independently — within
|
|
199
|
+
// a single URL field itself we want fail-fast.
|
|
200
|
+
if (typeof value !== "string") {
|
|
201
|
+
throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
|
|
202
|
+
}
|
|
203
|
+
if (!ALLOWED_URL_SCHEMES.some((scheme) => value.startsWith(scheme))) {
|
|
204
|
+
// Truncate to keep the error message compact and avoid
|
|
205
|
+
// pathologically long schemes from filling logs.
|
|
206
|
+
const repr = JSON.stringify(value).slice(0, 40);
|
|
207
|
+
throw clientValidationError(`${field}: must start with http:// or https:// (got ${repr})`);
|
|
208
|
+
}
|
|
209
|
+
if (value.length > MAX_TARGET_URL) {
|
|
210
|
+
throw clientValidationError(`${field}: must be ${MAX_TARGET_URL} characters or fewer (got ${value.length})`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function requireValidTargetUrl(target_url) {
|
|
214
|
+
requireValidHttpUrl(target_url, "target_url");
|
|
215
|
+
}
|
|
216
|
+
export function requireObjectInput(input, method) {
|
|
217
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
218
|
+
throw clientValidationError(`${method}: input must be an object (got ${input === null ? "null" : typeof input})`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export function requireNonEmptyStringField(input, field, method) {
|
|
222
|
+
const value = input[field];
|
|
223
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
224
|
+
throw clientValidationError(`${method}: ${field} must be a non-empty string`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function requireNonEmptyArrayField(input, field, method) {
|
|
228
|
+
const value = input[field];
|
|
229
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
230
|
+
throw clientValidationError(`${method}: ${field} must be a non-empty array`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export function requireStringArrayElements(value, field, method) {
|
|
234
|
+
if (!Array.isArray(value)) {
|
|
235
|
+
throw clientValidationError(`${method}: ${field} must be a non-empty array`);
|
|
236
|
+
}
|
|
237
|
+
value.forEach((entry, index) => {
|
|
238
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
239
|
+
throw clientValidationError(`${method}: ${field}[${index}] must be a non-empty string`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
export function requireAtLeastOneField(input, fields, method) {
|
|
244
|
+
if (!fields.some((field) => input[field] !== undefined)) {
|
|
245
|
+
throw clientValidationError(`${method}: at least one field (${fields.join(", ")}) must be provided`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function requireNoUnknownFields(input, fields, method) {
|
|
249
|
+
const unknown = Object.keys(input).filter((field) => !fields.includes(field));
|
|
250
|
+
if (unknown.length > 0) {
|
|
251
|
+
const rendered = unknown.map((field) => `"${field}"`).join(", ");
|
|
252
|
+
const label = unknown.length === 1 ? "unknown field" : "unknown fields";
|
|
253
|
+
throw clientValidationError(`${method}: ${label} ${rendered}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function normalizePatchFields(input, fields, options = {}) {
|
|
257
|
+
const preserveNull = new Set(options.preserveNullFields ?? []);
|
|
258
|
+
const normalized = {};
|
|
259
|
+
for (const key of fields) {
|
|
260
|
+
const value = input[key];
|
|
261
|
+
if (value !== undefined && (value !== null || preserveNull.has(key))) {
|
|
262
|
+
normalized[key] = value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return normalized;
|
|
266
|
+
}
|
|
267
|
+
export function requireConsumerDecision(id, version, allowNew = false) {
|
|
268
|
+
if (typeof id !== "string" ||
|
|
269
|
+
(!/^[a-f0-9]{64}$/.test(id) && !(allowNew && id === "new")) ||
|
|
270
|
+
!Number.isSafeInteger(version) ||
|
|
271
|
+
version < (allowNew ? 0 : 1))
|
|
272
|
+
throw clientValidationError("consumer decision: invalid id or expected version");
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Portal-verb target URL validation (qurl-go `ProtectURL` parity). On top of
|
|
276
|
+
* the shared scheme/length check, the URL must carry a host and must parse,
|
|
277
|
+
* and embedded credentials (userinfo) are rejected. The added checks never
|
|
278
|
+
* echo the URL — a userinfo URL contains credentials that must stay out of
|
|
279
|
+
* logs and error messages. The REST-shaped compatibility layer keeps its
|
|
280
|
+
* original prefix-only check so its accepted inputs don't change.
|
|
281
|
+
*/
|
|
282
|
+
export function requirePortalTargetUrl(targetUrl) {
|
|
283
|
+
requireValidTargetUrl(targetUrl);
|
|
284
|
+
const url = targetUrl; // requireValidTargetUrl only returns for http(s) strings
|
|
285
|
+
// WHATWG URL normalizes away an empty authority ("https:///x" parses
|
|
286
|
+
// with host "x"), so the host-presence check runs on the raw string,
|
|
287
|
+
// mirroring qurl-go's url.Parse which rejects authority-less URLs.
|
|
288
|
+
const afterScheme = url.slice(url.indexOf("://") + 3);
|
|
289
|
+
if (afterScheme === "" || "/?#".includes(afterScheme[0])) {
|
|
290
|
+
throw clientValidationError("target_url: must include a host");
|
|
291
|
+
}
|
|
292
|
+
let parsed;
|
|
293
|
+
try {
|
|
294
|
+
parsed = new URL(url);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
throw clientValidationError("target_url: malformed URL");
|
|
298
|
+
}
|
|
299
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
300
|
+
throw clientValidationError("target_url: must not include embedded credentials (userinfo)");
|
|
301
|
+
}
|
|
302
|
+
}
|