@dytsou/shorten-url 2.4.4 → 2.4.6
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 +88 -50
- package/config/config.example.js +137 -0
- package/config/config.js +19 -0
- package/docs/about.md +360 -0
- package/docs/api/openapi.yaml +441 -81
- package/docs/flagship.md +58 -0
- package/docs/plans/2026-09-06-001-refactor-worker-hosted-react-assets-plan.md +308 -0
- package/frontend/about-template.html +124 -0
- package/frontend/index.html +17 -0
- package/frontend/package.json +20 -0
- package/frontend/pnpm-lock.yaml +1166 -0
- package/frontend/pnpm-workspace.yaml +5 -0
- package/frontend/src/App.jsx +1264 -0
- package/frontend/src/api.js +272 -0
- package/frontend/src/local-flag-mock.js +55 -0
- package/frontend/src/main.jsx +4 -0
- package/frontend/src/styles/app.css +2253 -0
- package/frontend/static/favicon.svg +5 -0
- package/frontend/vite.config.js +34 -0
- package/package.json +6 -4
- package/src/lib/access.js +241 -1
- package/src/lib/assets.js +58 -0
- package/src/lib/endpoints.js +33 -8
- package/src/lib/feature-toggle.js +464 -0
- package/src/lib/flag-management.js +137 -0
- package/src/lib/flag-routes.js +155 -0
- package/src/lib/flagship.js +269 -0
- package/src/lib/observability.js +34 -0
- package/src/lib/responses.js +39 -3
- package/src/lib/runtime-config.js +90 -0
- package/src/lib/safety.js +15 -0
- package/src/lib/shortener.js +2 -2
- package/src/lib/worker-handler.js +122 -0
- package/src/worker.js +3 -66
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { isSafeDestination } from "./safety.js";
|
|
2
|
+
|
|
3
|
+
export const SHORTENING_FLAG_KEY = "shorten-routing";
|
|
4
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
const MAX_DEFINITION_BYTES = 100 * 1024;
|
|
6
|
+
const DEFINITION_FIELDS = new Set([
|
|
7
|
+
"key",
|
|
8
|
+
"description",
|
|
9
|
+
"enabled",
|
|
10
|
+
"defaultVariant",
|
|
11
|
+
"default_variation",
|
|
12
|
+
"variants",
|
|
13
|
+
"rules",
|
|
14
|
+
]);
|
|
15
|
+
const VARIANT_FIELDS = new Set(["key", "value", "url"]);
|
|
16
|
+
const RULE_FIELDS = new Set([
|
|
17
|
+
"priority",
|
|
18
|
+
"conditions",
|
|
19
|
+
"countries",
|
|
20
|
+
"country",
|
|
21
|
+
"variant",
|
|
22
|
+
"serve_variation",
|
|
23
|
+
"rolloutPercentage",
|
|
24
|
+
]);
|
|
25
|
+
export const FLAGSHIP_CONTEXT_ATTRIBUTES = Object.freeze([
|
|
26
|
+
"country",
|
|
27
|
+
"regionCode",
|
|
28
|
+
"continent",
|
|
29
|
+
"timezone",
|
|
30
|
+
"language",
|
|
31
|
+
"utmSource",
|
|
32
|
+
"utmMedium",
|
|
33
|
+
"utmCampaign",
|
|
34
|
+
]);
|
|
35
|
+
const CONTEXT_ATTRIBUTES = new Set(FLAGSHIP_CONTEXT_ATTRIBUTES);
|
|
36
|
+
const STRING_OPERATORS = new Set([
|
|
37
|
+
"equals",
|
|
38
|
+
"not_equals",
|
|
39
|
+
"contains",
|
|
40
|
+
"starts_with",
|
|
41
|
+
"ends_with",
|
|
42
|
+
"in",
|
|
43
|
+
"not_in",
|
|
44
|
+
]);
|
|
45
|
+
const ARRAY_OPERATORS = new Set(["in", "not_in"]);
|
|
46
|
+
const MAX_CONDITIONS_PER_RULE = 16;
|
|
47
|
+
const MAX_CONDITION_VALUES = 50;
|
|
48
|
+
const MAX_CONDITION_VALUE_LENGTH = 256;
|
|
49
|
+
|
|
50
|
+
export function normalizeCountry(country) {
|
|
51
|
+
if (typeof country !== "string") return null;
|
|
52
|
+
const normalized = country.trim().toUpperCase();
|
|
53
|
+
return /^[A-Z]{2}$/.test(normalized) ? normalized : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function byteLength(value) {
|
|
57
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateVariantValue(value, key) {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
62
|
+
return `${key} must contain an object value`;
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(value).some((field) => field !== "url")) {
|
|
65
|
+
return `${key} contains unsupported fields`;
|
|
66
|
+
}
|
|
67
|
+
if (!isSafeDestination(value.url)) return `${key} must contain a valid safe HTTP(S) url`;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateVariant(variant, variantKeys, errors) {
|
|
72
|
+
if (!variant || Object.keys(variant).some((field) => !VARIANT_FIELDS.has(field))) {
|
|
73
|
+
errors.push("variants contain unsupported fields");
|
|
74
|
+
}
|
|
75
|
+
if (variant?.value !== undefined && variant?.url !== undefined) {
|
|
76
|
+
errors.push("variants must use one value field shape");
|
|
77
|
+
}
|
|
78
|
+
if (!variant || !KEY_PATTERN.test(variant.key || "")) {
|
|
79
|
+
errors.push("variant keys must use letters, numbers, hyphens, or underscores");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (variantKeys.has(variant.key)) errors.push("variant keys must be unique");
|
|
83
|
+
variantKeys.add(variant.key);
|
|
84
|
+
const valueError = validateVariantValue(variant.value || { url: variant.url }, variant.key);
|
|
85
|
+
if (valueError) errors.push(valueError);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateVariants(variants, errors) {
|
|
89
|
+
if (!Array.isArray(variants) || variants.length === 0) {
|
|
90
|
+
errors.push("at least one variant is required");
|
|
91
|
+
}
|
|
92
|
+
const variantKeys = new Set();
|
|
93
|
+
for (const variant of Array.isArray(variants) ? variants : []) {
|
|
94
|
+
validateVariant(variant, variantKeys, errors);
|
|
95
|
+
}
|
|
96
|
+
return variantKeys;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateDescription(description, errors) {
|
|
100
|
+
if (description === undefined || description === null) return;
|
|
101
|
+
if (typeof description !== "string" || description.length > 512) {
|
|
102
|
+
errors.push("description must be at most 512 characters");
|
|
103
|
+
}
|
|
104
|
+
if (typeof description === "string" && /<|>|javascript:|<\/?script/i.test(description)) {
|
|
105
|
+
errors.push("description contains unsupported markup");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ruleCountries(rule) {
|
|
110
|
+
if (Array.isArray(rule?.countries)) return rule.countries;
|
|
111
|
+
if (rule?.country) return [rule.country];
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ruleConditions(rule) {
|
|
116
|
+
if (Array.isArray(rule?.conditions)) return rule.conditions;
|
|
117
|
+
const countries = ruleCountries(rule);
|
|
118
|
+
return countries.length
|
|
119
|
+
? [{ attribute: "country", operator: "in", value: countries.map(normalizeCountry) }]
|
|
120
|
+
: [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function conditionValues(condition) {
|
|
124
|
+
return Array.isArray(condition?.value) ? condition.value : [condition?.value];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validateCondition(condition, errors) {
|
|
128
|
+
const allowedFields = new Set(["attribute", "operator", "value"]);
|
|
129
|
+
if (
|
|
130
|
+
!condition ||
|
|
131
|
+
typeof condition !== "object" ||
|
|
132
|
+
Array.isArray(condition) ||
|
|
133
|
+
Object.keys(condition).some((field) => !allowedFields.has(field))
|
|
134
|
+
) {
|
|
135
|
+
errors.push("rule conditions contain unsupported fields");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!CONTEXT_ATTRIBUTES.has(condition.attribute)) {
|
|
139
|
+
errors.push("rule conditions must use a context attribute available to the Worker");
|
|
140
|
+
}
|
|
141
|
+
if (!STRING_OPERATORS.has(condition.operator)) {
|
|
142
|
+
errors.push("rule conditions contain an unsupported operator");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (ARRAY_OPERATORS.has(condition.operator)) {
|
|
146
|
+
if (
|
|
147
|
+
!Array.isArray(condition.value) ||
|
|
148
|
+
condition.value.length === 0 ||
|
|
149
|
+
condition.value.length > MAX_CONDITION_VALUES ||
|
|
150
|
+
condition.value.some(
|
|
151
|
+
(value) =>
|
|
152
|
+
typeof value !== "string" || !value.trim() || value.length > MAX_CONDITION_VALUE_LENGTH
|
|
153
|
+
)
|
|
154
|
+
) {
|
|
155
|
+
errors.push("in and not_in conditions need a list of non-empty values");
|
|
156
|
+
}
|
|
157
|
+
} else if (
|
|
158
|
+
typeof condition.value !== "string" ||
|
|
159
|
+
!condition.value.trim() ||
|
|
160
|
+
condition.value.length > MAX_CONDITION_VALUE_LENGTH
|
|
161
|
+
) {
|
|
162
|
+
errors.push("rule condition values must be non-empty strings up to 256 characters");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (condition.attribute === "country") {
|
|
166
|
+
const values = conditionValues(condition);
|
|
167
|
+
if (values.some((value) => !normalizeCountry(value))) {
|
|
168
|
+
errors.push("country condition values must be valid two-letter country codes");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateRuleShape(rule, errors) {
|
|
174
|
+
if (!rule || Object.keys(rule).some((field) => !RULE_FIELDS.has(field))) {
|
|
175
|
+
errors.push("rules contain unsupported fields");
|
|
176
|
+
}
|
|
177
|
+
if (rule?.conditions !== undefined && !Array.isArray(rule.conditions)) {
|
|
178
|
+
errors.push("rule conditions must be an array");
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
(Array.isArray(rule?.countries) && rule.country !== undefined) ||
|
|
182
|
+
(rule?.conditions !== undefined &&
|
|
183
|
+
(rule.countries !== undefined || rule.country !== undefined)) ||
|
|
184
|
+
(rule?.variant !== undefined && rule.serve_variation !== undefined)
|
|
185
|
+
) {
|
|
186
|
+
errors.push("rules must use one targeting field shape");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateRulePriorityAndVariant(rule, variantKeys, priorities, errors) {
|
|
191
|
+
if (!Number.isInteger(rule?.priority) || rule.priority < 1 || priorities.has(rule.priority)) {
|
|
192
|
+
errors.push("rule priorities must be unique positive integers");
|
|
193
|
+
}
|
|
194
|
+
priorities.add(rule?.priority);
|
|
195
|
+
if (!variantKeys.has(rule?.variant || rule?.serve_variation)) {
|
|
196
|
+
errors.push("rule variant must exist");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function validateRuleConditions(conditions, errors) {
|
|
201
|
+
if (conditions.length > MAX_CONDITIONS_PER_RULE) {
|
|
202
|
+
errors.push(`rules can contain at most ${MAX_CONDITIONS_PER_RULE} conditions`);
|
|
203
|
+
}
|
|
204
|
+
const conditionKeys = new Set();
|
|
205
|
+
for (const condition of conditions) {
|
|
206
|
+
validateCondition(condition, errors);
|
|
207
|
+
const key = JSON.stringify(condition);
|
|
208
|
+
if (conditionKeys.has(key)) errors.push("rule conditions must be unique");
|
|
209
|
+
conditionKeys.add(key);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateRuleRollout(rule, errors) {
|
|
214
|
+
const hasRollout = rule?.rolloutPercentage !== undefined && rule?.rolloutPercentage !== null;
|
|
215
|
+
if (
|
|
216
|
+
hasRollout &&
|
|
217
|
+
(typeof rule.rolloutPercentage !== "number" ||
|
|
218
|
+
!Number.isFinite(rule.rolloutPercentage) ||
|
|
219
|
+
rule.rolloutPercentage < 0 ||
|
|
220
|
+
rule.rolloutPercentage > 100 ||
|
|
221
|
+
Math.abs(rule.rolloutPercentage * 100 - Math.round(rule.rolloutPercentage * 100)) > 1e-8)
|
|
222
|
+
) {
|
|
223
|
+
errors.push("rollout percentages must be between 0 and 100 with at most two decimals");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function validateRule(rule, variantKeys, priorities, errors) {
|
|
228
|
+
validateRuleShape(rule, errors);
|
|
229
|
+
validateRulePriorityAndVariant(rule, variantKeys, priorities, errors);
|
|
230
|
+
const conditions = ruleConditions(rule);
|
|
231
|
+
validateRuleConditions(conditions, errors);
|
|
232
|
+
validateRuleRollout(rule, errors);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function validateRules(rules, variantKeys, errors) {
|
|
236
|
+
if (rules !== undefined && !Array.isArray(rules)) {
|
|
237
|
+
errors.push("rules must be an array");
|
|
238
|
+
}
|
|
239
|
+
const priorities = new Set();
|
|
240
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
241
|
+
validateRule(rule, variantKeys, priorities, errors);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizedVariant(variant) {
|
|
246
|
+
return {
|
|
247
|
+
key: variant.key,
|
|
248
|
+
value: { url: (variant.value || { url: variant.url }).url },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeConditionValue(condition) {
|
|
253
|
+
if (ARRAY_OPERATORS.has(condition.operator)) {
|
|
254
|
+
return condition.value.map((value) => {
|
|
255
|
+
if (condition.attribute === "country") return normalizeCountry(value);
|
|
256
|
+
return value.trim();
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (condition.attribute === "country") return normalizeCountry(condition.value);
|
|
260
|
+
return condition.value.trim();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizedRule(rule) {
|
|
264
|
+
const normalized = {
|
|
265
|
+
priority: rule.priority,
|
|
266
|
+
variant: rule.variant || rule.serve_variation,
|
|
267
|
+
conditions: ruleConditions(rule).map((condition) => ({
|
|
268
|
+
attribute: condition.attribute,
|
|
269
|
+
operator: condition.operator,
|
|
270
|
+
value: normalizeConditionValue(condition),
|
|
271
|
+
})),
|
|
272
|
+
};
|
|
273
|
+
if (rule.rolloutPercentage !== undefined && rule.rolloutPercentage !== null) {
|
|
274
|
+
normalized.rolloutPercentage = rule.rolloutPercentage;
|
|
275
|
+
}
|
|
276
|
+
return normalized;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizedContext(contextOrCountry, targetingKey) {
|
|
280
|
+
const context =
|
|
281
|
+
contextOrCountry && typeof contextOrCountry === "object" && !Array.isArray(contextOrCountry)
|
|
282
|
+
? contextOrCountry
|
|
283
|
+
: { country: contextOrCountry };
|
|
284
|
+
const normalized = {};
|
|
285
|
+
for (const attribute of CONTEXT_ATTRIBUTES) {
|
|
286
|
+
const value = context[attribute];
|
|
287
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
288
|
+
normalized[attribute] = attribute === "country" ? normalizeCountry(value) : value.trim();
|
|
289
|
+
}
|
|
290
|
+
const stableKey = context.targetingKey || targetingKey;
|
|
291
|
+
if (typeof stableKey === "string" && stableKey) normalized.targetingKey = stableKey;
|
|
292
|
+
return normalized;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function conditionMatches(condition, context) {
|
|
296
|
+
const actual = context[condition.attribute];
|
|
297
|
+
if (actual === undefined || actual === null) return false;
|
|
298
|
+
if (condition.operator === "equals") return actual === condition.value;
|
|
299
|
+
if (condition.operator === "not_equals") return actual !== condition.value;
|
|
300
|
+
if (condition.operator === "contains") return actual.includes(condition.value);
|
|
301
|
+
if (condition.operator === "starts_with") return actual.startsWith(condition.value);
|
|
302
|
+
if (condition.operator === "ends_with") return actual.endsWith(condition.value);
|
|
303
|
+
if (condition.operator === "in") return condition.value.includes(actual);
|
|
304
|
+
if (condition.operator === "not_in") return !condition.value.includes(actual);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Validate the small JSON flag definition used by the settings control plane.
|
|
310
|
+
* The provider-specific envelope is deliberately kept out of this boundary.
|
|
311
|
+
*/
|
|
312
|
+
export function validateFlagDefinition(input, { expectedKey = SHORTENING_FLAG_KEY } = {}) {
|
|
313
|
+
const errors = [];
|
|
314
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
315
|
+
return { ok: false, errors: ["definition must be an object"] };
|
|
316
|
+
}
|
|
317
|
+
for (const field of Object.keys(input)) {
|
|
318
|
+
if (!DEFINITION_FIELDS.has(field)) errors.push(`unsupported field: ${field}`);
|
|
319
|
+
}
|
|
320
|
+
const key = input.key || expectedKey;
|
|
321
|
+
if (key !== expectedKey || !KEY_PATTERN.test(key)) errors.push("invalid flag key");
|
|
322
|
+
|
|
323
|
+
const variants = input.variants;
|
|
324
|
+
const variantKeys = validateVariants(variants, errors);
|
|
325
|
+
|
|
326
|
+
const defaultVariant = input.defaultVariant || input.default_variation;
|
|
327
|
+
if (!variantKeys.has(defaultVariant)) errors.push("default variant must exist");
|
|
328
|
+
|
|
329
|
+
if (typeof input.enabled !== "boolean") errors.push("enabled must be a boolean");
|
|
330
|
+
validateDescription(input.description, errors);
|
|
331
|
+
validateRules(input.rules, variantKeys, errors);
|
|
332
|
+
|
|
333
|
+
if (byteLength(input) > MAX_DEFINITION_BYTES) errors.push("definition is too large");
|
|
334
|
+
if (errors.length) return { ok: false, errors };
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
errors: [],
|
|
339
|
+
value: {
|
|
340
|
+
key,
|
|
341
|
+
description: input.description || "",
|
|
342
|
+
enabled: input.enabled,
|
|
343
|
+
defaultVariant,
|
|
344
|
+
variants: variants.map(normalizedVariant),
|
|
345
|
+
rules: (input.rules || []).map(normalizedRule).sort((a, b) => a.priority - b.priority),
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function percentageBucket(targetingKey) {
|
|
351
|
+
let hash = 2166136261;
|
|
352
|
+
for (const byte of new TextEncoder().encode(targetingKey)) {
|
|
353
|
+
hash = Math.imul(hash ^ byte, 16777619);
|
|
354
|
+
}
|
|
355
|
+
return ((hash >>> 0) / 0x100000000) * 100;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function evaluateFlagDefinition(definition, contextOrCountry, targetingKey) {
|
|
359
|
+
const context = normalizedContext(contextOrCountry, targetingKey);
|
|
360
|
+
const normalizedCountry = context.country || null;
|
|
361
|
+
if (!definition?.enabled) {
|
|
362
|
+
return { outcome: "unpublished", country: normalizedCountry, fallbackReason: "disabled" };
|
|
363
|
+
}
|
|
364
|
+
for (const rule of definition.rules || []) {
|
|
365
|
+
if (!ruleConditions(rule).every((condition) => conditionMatches(condition, context))) continue;
|
|
366
|
+
if (rule.rolloutPercentage !== undefined) {
|
|
367
|
+
if (
|
|
368
|
+
typeof context.targetingKey !== "string" ||
|
|
369
|
+
!context.targetingKey ||
|
|
370
|
+
percentageBucket(context.targetingKey) >= rule.rolloutPercentage
|
|
371
|
+
) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const variant = definition.variants.find((item) => item.key === rule.variant);
|
|
376
|
+
if (variant) {
|
|
377
|
+
return {
|
|
378
|
+
outcome: "matched",
|
|
379
|
+
country: normalizedCountry,
|
|
380
|
+
variant: variant.key,
|
|
381
|
+
destination: variant.value.url,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
outcome: "unmatched",
|
|
387
|
+
country: normalizedCountry,
|
|
388
|
+
fallbackReason: normalizedCountry ? "no_match" : "missing_country",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function toProviderFlag(definition) {
|
|
393
|
+
const validated = validateFlagDefinition(definition);
|
|
394
|
+
if (!validated.ok) return validated;
|
|
395
|
+
const value = validated.value;
|
|
396
|
+
return {
|
|
397
|
+
key: value.key,
|
|
398
|
+
type: "json",
|
|
399
|
+
default_variation: value.defaultVariant,
|
|
400
|
+
variations: Object.fromEntries(value.variants.map((variant) => [variant.key, variant.value])),
|
|
401
|
+
rules: value.rules.map((rule) => ({
|
|
402
|
+
priority: rule.priority,
|
|
403
|
+
conditions: rule.conditions,
|
|
404
|
+
...(rule.rolloutPercentage === undefined
|
|
405
|
+
? {}
|
|
406
|
+
: {
|
|
407
|
+
rollout: {
|
|
408
|
+
percentage: rule.rolloutPercentage,
|
|
409
|
+
attribute: "targetingKey",
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
serve_variation: rule.variant,
|
|
413
|
+
})),
|
|
414
|
+
description: value.description || null,
|
|
415
|
+
enabled: value.enabled,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function fromProviderFlag(flag) {
|
|
420
|
+
if (!flag || typeof flag !== "object") return null;
|
|
421
|
+
if (
|
|
422
|
+
(flag.type && flag.type !== "json") ||
|
|
423
|
+
(flag.rules || []).some((rule) => {
|
|
424
|
+
const conditions = rule?.conditions ?? [];
|
|
425
|
+
const rollout = rule?.rollout;
|
|
426
|
+
const hasUnsupportedRollout =
|
|
427
|
+
rollout !== undefined &&
|
|
428
|
+
rollout !== null &&
|
|
429
|
+
(typeof rollout !== "object" ||
|
|
430
|
+
Array.isArray(rollout) ||
|
|
431
|
+
Object.keys(rollout).some((field) => !["percentage", "attribute"].includes(field)) ||
|
|
432
|
+
typeof rollout.percentage !== "number" ||
|
|
433
|
+
(rollout.attribute !== undefined && rollout.attribute !== "targetingKey"));
|
|
434
|
+
return !Array.isArray(conditions) || hasUnsupportedRollout;
|
|
435
|
+
})
|
|
436
|
+
) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const variants = Object.entries(flag.variations || {}).map(([key, value]) => ({
|
|
440
|
+
key,
|
|
441
|
+
value,
|
|
442
|
+
}));
|
|
443
|
+
const rules = (flag.rules || []).map((rule) => {
|
|
444
|
+
const normalized = {
|
|
445
|
+
priority: rule.priority,
|
|
446
|
+
variant: rule.serve_variation,
|
|
447
|
+
conditions: rule.conditions || [],
|
|
448
|
+
};
|
|
449
|
+
if (rule.rollout !== undefined && rule.rollout !== null) {
|
|
450
|
+
normalized.rolloutPercentage = rule.rollout.percentage;
|
|
451
|
+
}
|
|
452
|
+
return normalized;
|
|
453
|
+
});
|
|
454
|
+
const candidate = {
|
|
455
|
+
key: flag.key,
|
|
456
|
+
description: flag.description || "",
|
|
457
|
+
enabled: flag.enabled === true,
|
|
458
|
+
defaultVariant: flag.default_variation,
|
|
459
|
+
variants,
|
|
460
|
+
rules,
|
|
461
|
+
};
|
|
462
|
+
const validated = validateFlagDefinition(candidate);
|
|
463
|
+
return validated.ok ? validated.value : null;
|
|
464
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { authorizeSettingsRequest, createCsrfToken } from "./access.js";
|
|
2
|
+
import { SHORTENING_FLAG_KEY, fromProviderFlag, validateFlagDefinition } from "./feature-toggle.js";
|
|
3
|
+
import { FlagshipManagementError } from "./flagship.js";
|
|
4
|
+
import { log, logError } from "./observability.js";
|
|
5
|
+
|
|
6
|
+
const MAX_BODY_BYTES = 100 * 1024;
|
|
7
|
+
|
|
8
|
+
function json(response, payload, status = 200) {
|
|
9
|
+
const result = response.jsonResponse(payload, status);
|
|
10
|
+
result.headers.set("cache-control", "no-store");
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function publicFlag(flag) {
|
|
15
|
+
const normalized = fromProviderFlag(flag);
|
|
16
|
+
if (!normalized) return null;
|
|
17
|
+
return {
|
|
18
|
+
...normalized,
|
|
19
|
+
updatedAt: typeof flag.updated_at === "string" ? flag.updated_at : null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readJson(request) {
|
|
24
|
+
const length = Number(request.headers.get("content-length") || 0);
|
|
25
|
+
if (length > MAX_BODY_BYTES) throw new FlagshipManagementError("Payload too large", 413);
|
|
26
|
+
const text = await request.text();
|
|
27
|
+
if (new TextEncoder().encode(text).byteLength > MAX_BODY_BYTES) {
|
|
28
|
+
throw new FlagshipManagementError("Payload too large", 413);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new FlagshipManagementError("Invalid JSON format", 400);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeError(error) {
|
|
38
|
+
if (error instanceof FlagshipManagementError) {
|
|
39
|
+
return {
|
|
40
|
+
status: error.status,
|
|
41
|
+
message: error.status === 400 ? error.message : "Flagship request failed",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { status: 502, message: "Flagship request failed" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createFlagManagement({ adapter, responses, env, verifyToken, allowedOrigins }) {
|
|
48
|
+
async function authorized(request, mutation = false) {
|
|
49
|
+
return authorizeSettingsRequest(request, env, { mutation, verifyToken, allowedOrigins });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleCsrf(request) {
|
|
53
|
+
const token = await createCsrfToken(request, env);
|
|
54
|
+
if (!token)
|
|
55
|
+
return json(responses, { status: 503, message: "Settings are not configured" }, 503);
|
|
56
|
+
return json(responses, { csrfToken: token });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function handleFlags() {
|
|
60
|
+
const flags = await adapter.listFlags();
|
|
61
|
+
const result = (Array.isArray(flags) ? flags : [flags])
|
|
62
|
+
.map(publicFlag)
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.filter((flag) => flag.key === SHORTENING_FLAG_KEY);
|
|
65
|
+
return json(responses, { flags: result });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleCreateFlag(request) {
|
|
69
|
+
const body = await readJson(request);
|
|
70
|
+
const validated = validateFlagDefinition(body);
|
|
71
|
+
if (!validated.ok) {
|
|
72
|
+
return json(responses, { status: 400, message: validated.errors.join("; ") }, 400);
|
|
73
|
+
}
|
|
74
|
+
const result = await adapter.createFlag(validated.value);
|
|
75
|
+
return json(responses, { flag: publicFlag(result) }, 201);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleSaveFlag(request) {
|
|
79
|
+
const body = await readJson(request);
|
|
80
|
+
const { expectedUpdatedAt, ...definition } = body;
|
|
81
|
+
if (typeof expectedUpdatedAt !== "string" || !expectedUpdatedAt) {
|
|
82
|
+
return json(responses, { status: 409, message: "A current version is required" }, 409);
|
|
83
|
+
}
|
|
84
|
+
const validated = validateFlagDefinition(definition);
|
|
85
|
+
if (!validated.ok) {
|
|
86
|
+
return json(responses, { status: 400, message: validated.errors.join("; ") }, 400);
|
|
87
|
+
}
|
|
88
|
+
const result = await adapter.saveFlag(validated.value, { expectedUpdatedAt });
|
|
89
|
+
return json(responses, { flag: publicFlag(result) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handlePublishFlag(request) {
|
|
93
|
+
const { expectedUpdatedAt } = await readJson(request);
|
|
94
|
+
if (typeof expectedUpdatedAt !== "string" || !expectedUpdatedAt) {
|
|
95
|
+
return json(responses, { status: 409, message: "A current version is required" }, 409);
|
|
96
|
+
}
|
|
97
|
+
const current = await adapter.getFlag(SHORTENING_FLAG_KEY);
|
|
98
|
+
const definition = fromProviderFlag(current);
|
|
99
|
+
if (!definition) throw new FlagshipManagementError("Invalid Flagship definition", 502);
|
|
100
|
+
const result = await adapter.publishFlag(definition, expectedUpdatedAt);
|
|
101
|
+
return json(responses, { flag: publicFlag(result) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function dispatch(request, route) {
|
|
105
|
+
if (route === "csrf" && request.method === "GET") return handleCsrf(request);
|
|
106
|
+
if (route === "flags" && request.method === "GET") return handleFlags();
|
|
107
|
+
if (route === "flag" && request.method === "POST") return handleCreateFlag(request);
|
|
108
|
+
if (route.startsWith("flag:") && request.method === "PUT") return handleSaveFlag(request);
|
|
109
|
+
if (route.startsWith("publish:") && request.method === "POST") {
|
|
110
|
+
return handlePublishFlag(request);
|
|
111
|
+
}
|
|
112
|
+
return json(responses, { status: 404, message: "Settings route not found" }, 404);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function handle(request, route) {
|
|
116
|
+
const mutation = request.method !== "GET";
|
|
117
|
+
const authorization = await authorized(request, mutation);
|
|
118
|
+
if (!authorization.ok) {
|
|
119
|
+
log("warn", "flagship.settings_denied", { reason: authorization.reason });
|
|
120
|
+
return json(
|
|
121
|
+
responses,
|
|
122
|
+
{ status: authorization.status, message: "Access denied" },
|
|
123
|
+
authorization.status
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
return await dispatch(request, route);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
logError("flagship.settings_failed", error, { route });
|
|
131
|
+
const failure = safeError(error);
|
|
132
|
+
return json(responses, { status: failure.status, message: failure.message }, failure.status);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { handle };
|
|
137
|
+
}
|