@forthtilliath/ts-kit 0.14.0 → 0.16.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 +68 -0
- package/dist/array/index.d.ts +1 -0
- package/dist/array/index.d.ts.map +1 -1
- package/dist/array/index.js +1 -0
- package/dist/array/pickRandom.d.ts +14 -0
- package/dist/array/pickRandom.d.ts.map +1 -0
- package/dist/array/pickRandom.js +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/number/index.d.ts +2 -0
- package/dist/number/index.d.ts.map +1 -1
- package/dist/number/index.js +2 -0
- package/dist/number/parseIntegerInput.d.ts +17 -0
- package/dist/number/parseIntegerInput.d.ts.map +1 -0
- package/dist/number/parseIntegerInput.js +25 -0
- package/dist/number/sanitizeIntegerInput.d.ts +26 -0
- package/dist/number/sanitizeIntegerInput.d.ts.map +1 -0
- package/dist/number/sanitizeIntegerInput.js +22 -0
- package/dist/security/createAttemptLimiter.d.ts +65 -0
- package/dist/security/createAttemptLimiter.d.ts.map +1 -0
- package/dist/security/createAttemptLimiter.js +96 -0
- package/dist/security/index.d.ts +4 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/parseUntrustedJson.d.ts +70 -0
- package/dist/security/parseUntrustedJson.d.ts.map +1 -0
- package/dist/security/parseUntrustedJson.js +93 -0
- package/dist/security/standardSchema.d.ts +29 -0
- package/dist/security/standardSchema.d.ts.map +1 -0
- package/dist/security/standardSchema.js +1 -0
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ Importing them is harmless anywhere: the DOM is only touched when called.
|
|
|
68
68
|
- `rankByNameMatch(items, query, getName)` — ranks `items` by relevance to
|
|
69
69
|
`query`: earlier match position first, then shorter name — for a
|
|
70
70
|
search-as-you-type list.
|
|
71
|
+
- `pickRandom(array, random?)` — one element picked at random, or
|
|
72
|
+
`undefined` for an empty array; same `random` as `shuffle`.
|
|
71
73
|
- `shuffle(array, random?)` — shuffled copy (Fisher–Yates); `random`
|
|
72
74
|
(default `Math.random`) can be swapped for a seeded generator in tests.
|
|
73
75
|
- `sortUpcomingFirst(items, getDates, { now? })` — upcoming items first
|
|
@@ -252,6 +254,22 @@ Building blocks for writing an iCalendar (`.ics`, RFC 5545) file.
|
|
|
252
254
|
- `formatDuration(ms)` — human-readable duration ("1d 1h 1m 1s").
|
|
253
255
|
- `padNumber(n, total, minWidth?)` — zero-pads `n` to the width of `total`
|
|
254
256
|
(at least `minWidth`, default 2) so numbered file names sort correctly.
|
|
257
|
+
- `sanitizeIntegerInput(raw, { allowNegative? })` — cleans a whole-number
|
|
258
|
+
field keystroke by keystroke: digits only, plus a minus sign in first
|
|
259
|
+
position (`allowNegative`, default `true`). A lone `"-"` survives, so a
|
|
260
|
+
negative number types normally (Android's `number-pad` has no minus key:
|
|
261
|
+
use `keyboardType="numeric"`).
|
|
262
|
+
- `parseIntegerInput(raw)` — the integer a field's text stands for, or `null`
|
|
263
|
+
while it isn't one yet (`""`, `"-"`, `"1-2"`, `"1.5"`, beyond
|
|
264
|
+
`Number.MAX_SAFE_INTEGER`): keep the last valid value instead of writing
|
|
265
|
+
`0` over the text.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const cleaned = sanitizeIntegerInput(text);
|
|
269
|
+
const value = parseIntegerInput(cleaned);
|
|
270
|
+
setDraft(cleaned);
|
|
271
|
+
if (value !== null) onChange(value);
|
|
272
|
+
```
|
|
255
273
|
|
|
256
274
|
`sum` and `avg` used to live in `maths/`; those deep imports still work
|
|
257
275
|
(deprecated).
|
|
@@ -266,6 +284,56 @@ Building blocks for writing an iCalendar (`.ics`, RFC 5545) file.
|
|
|
266
284
|
- `deepEqual(a, b)` — recursive structural equality check.
|
|
267
285
|
- `mapValues(obj, fn)` — maps every value of an object through `fn`.
|
|
268
286
|
|
|
287
|
+
### `security`
|
|
288
|
+
|
|
289
|
+
- `createAttemptLimiter({ storage, key, freeAttempts?, delaysMs?, now? })` —
|
|
290
|
+
growing delays between attempts at a secret (a PIN…): 4 free failures,
|
|
291
|
+
then 30 s, 1 min, 5 min, 15 min for each one after. `attempt(verify)`
|
|
292
|
+
returns `{ ok: true }` or `{ ok: false, lockedUntil }` (0 when no delay
|
|
293
|
+
is running) and doesn't call `verify` while locked; `getLockedUntil()`,
|
|
294
|
+
`reset()`. The counter lives in the injected `storage` (e.g.
|
|
295
|
+
expo-secure-store's `getItemAsync`/`setItemAsync`/`deleteItemAsync`); an
|
|
296
|
+
unreadable or tampered value counts as used up, not as zero, a clock set
|
|
297
|
+
back restarts the delay from now, and parallel attempts are serialized.
|
|
298
|
+
Limit: the delay is measured with the device clock, so setting it forward
|
|
299
|
+
ends the wait early — the failure count still grows, and each new failure
|
|
300
|
+
imposes the longest delay again. Keep the secret itself out of reach
|
|
301
|
+
(stored hashed, in the Android Keystore through expo-secure-store).
|
|
302
|
+
- `parseUntrustedJson(text, schema, { maxLength?, locale?, messages?, describePath? })`
|
|
303
|
+
— reads JSON that comes from outside the app (imported file, pasted text,
|
|
304
|
+
QR code…) **all or nothing**: the first problem rejects the whole text.
|
|
305
|
+
Checks, in order: empty → longer than `maxLength` (1,000,000 characters by
|
|
306
|
+
default, checked _before_ `JSON.parse`) → malformed → drops every
|
|
307
|
+
`__proto__` / `constructor` / `prototype` key → validates with `schema`,
|
|
308
|
+
any [Standard Schema](https://standardschema.dev) (zod 3.24+/4, Valibot,
|
|
309
|
+
ArkType — no dependency added). Returns `{ ok: true, data }` or
|
|
310
|
+
`{ ok: false, reason, error, path?, issues?, raw? }`: `reason` is
|
|
311
|
+
`"empty" | "too-long" | "malformed" | "invalid"`, `error` a French message
|
|
312
|
+
ready to show (`locale: "en"` for English, `messages` to override each
|
|
313
|
+
one), `path` the first issue's path (`["players", 2, "name"]`, shown as
|
|
314
|
+
« players › n° 3 › name »; `describePath` to name fields), `raw` the
|
|
315
|
+
parsed but unvalidated value (to read a `version` and say "update the
|
|
316
|
+
app" — never use it as data). Synchronous schemas only.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { parseUntrustedJson } from "@forthtilliath/ts-kit/security/parseUntrustedJson";
|
|
320
|
+
import { z } from "zod";
|
|
321
|
+
|
|
322
|
+
// Bound everything: lengths, ranges, enums, array sizes.
|
|
323
|
+
const gameSchema = z.object({
|
|
324
|
+
name: z.string().trim().min(1).max(60),
|
|
325
|
+
players: z.array(z.string().max(30)).min(1).max(12),
|
|
326
|
+
rounds: z.number().int().min(1).max(50),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const result = parseUntrustedJson(text, gameSchema, {
|
|
330
|
+
maxLength: 50_000, // a real game weighs a few KB
|
|
331
|
+
describePath: (path) => FIELD_NAMES[String(path.at(-1))] ?? path.join(" › "),
|
|
332
|
+
});
|
|
333
|
+
if (!result.ok) return Alert.alert("Import impossible", result.error);
|
|
334
|
+
addGame(result.data); // typed as z.infer<typeof gameSchema>
|
|
335
|
+
```
|
|
336
|
+
|
|
269
337
|
### `string`
|
|
270
338
|
|
|
271
339
|
- `capitalize(str)` — uppercases the first letter, lowercases the rest.
|
package/dist/array/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./getMostRecentIds.js";
|
|
|
5
5
|
export * from "./indexById.js";
|
|
6
6
|
export * from "./moveItem.js";
|
|
7
7
|
export * from "./nextInCycle.js";
|
|
8
|
+
export * from "./pickRandom.js";
|
|
8
9
|
export * from "./rankByNameMatch.js";
|
|
9
10
|
export * from "./shuffle.js";
|
|
10
11
|
export * from "./sortUpcomingFirst.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/array/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/array/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,gBAAgB,CAAC"}
|
package/dist/array/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./getMostRecentIds.js";
|
|
|
5
5
|
export * from "./indexById.js";
|
|
6
6
|
export * from "./moveItem.js";
|
|
7
7
|
export * from "./nextInCycle.js";
|
|
8
|
+
export * from "./pickRandom.js";
|
|
8
9
|
export * from "./rankByNameMatch.js";
|
|
9
10
|
export * from "./shuffle.js";
|
|
10
11
|
export * from "./sortUpcomingFirst.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns one element of an array, picked at random (each equally likely).
|
|
3
|
+
*
|
|
4
|
+
* @param array - The array to pick from.
|
|
5
|
+
* @param random - Returns a number in `[0, 1)`. Defaults to `Math.random`;
|
|
6
|
+
* pass a seeded generator for deterministic tests.
|
|
7
|
+
* @returns The picked element, or `undefined` if `array` is empty.
|
|
8
|
+
* @example
|
|
9
|
+
* pickRandom(["rock", "paper", "scissors"]); // => e.g. "paper"
|
|
10
|
+
* pickRandom([1, 2, 3], () => 0.99); // => 3
|
|
11
|
+
* pickRandom([]); // => undefined
|
|
12
|
+
*/
|
|
13
|
+
export declare function pickRandom<T>(array: readonly T[], random?: () => number): T | undefined;
|
|
14
|
+
//# sourceMappingURL=pickRandom.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pickRandom.d.ts","sourceRoot":"","sources":["../../src/array/pickRandom.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAC1B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,MAAM,GAAE,MAAM,MAAoB,GACjC,CAAC,GAAG,SAAS,CAEf"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns one element of an array, picked at random (each equally likely).
|
|
3
|
+
*
|
|
4
|
+
* @param array - The array to pick from.
|
|
5
|
+
* @param random - Returns a number in `[0, 1)`. Defaults to `Math.random`;
|
|
6
|
+
* pass a seeded generator for deterministic tests.
|
|
7
|
+
* @returns The picked element, or `undefined` if `array` is empty.
|
|
8
|
+
* @example
|
|
9
|
+
* pickRandom(["rock", "paper", "scissors"]); // => e.g. "paper"
|
|
10
|
+
* pickRandom([1, 2, 3], () => 0.99); // => 3
|
|
11
|
+
* pickRandom([]); // => undefined
|
|
12
|
+
*/
|
|
13
|
+
export function pickRandom(array, random = Math.random) {
|
|
14
|
+
return array[Math.floor(random() * array.length)];
|
|
15
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./image/index.js";
|
|
|
12
12
|
export * from "./markdown/index.js";
|
|
13
13
|
export * from "./number/index.js";
|
|
14
14
|
export * from "./object/index.js";
|
|
15
|
+
export * from "./security/index.js";
|
|
15
16
|
export * from "./string/index.js";
|
|
16
17
|
export * from "./version/index.js";
|
|
17
18
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -17,5 +17,6 @@ export * from "./image/index.js";
|
|
|
17
17
|
export * from "./markdown/index.js";
|
|
18
18
|
export * from "./number/index.js";
|
|
19
19
|
export * from "./object/index.js";
|
|
20
|
+
export * from "./security/index.js";
|
|
20
21
|
export * from "./string/index.js";
|
|
21
22
|
export * from "./version/index.js";
|
package/dist/number/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export * from "./formatBytes.js";
|
|
|
4
4
|
export * from "./formatCsvNumber.js";
|
|
5
5
|
export * from "./formatDuration.js";
|
|
6
6
|
export * from "./padNumber.js";
|
|
7
|
+
export * from "./parseIntegerInput.js";
|
|
7
8
|
export * from "./round.js";
|
|
9
|
+
export * from "./sanitizeIntegerInput.js";
|
|
8
10
|
export * from "./sum.js";
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/number/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/number/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,YAAY,CAAC;AAC3B,cAAc,2BAA2B,CAAC;AAC1C,cAAc,UAAU,CAAC"}
|
package/dist/number/index.js
CHANGED
|
@@ -4,5 +4,7 @@ export * from "./formatBytes.js";
|
|
|
4
4
|
export * from "./formatCsvNumber.js";
|
|
5
5
|
export * from "./formatDuration.js";
|
|
6
6
|
export * from "./padNumber.js";
|
|
7
|
+
export * from "./parseIntegerInput.js";
|
|
7
8
|
export * from "./round.js";
|
|
9
|
+
export * from "./sanitizeIntegerInput.js";
|
|
8
10
|
export * from "./sum.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole number a field's text stands for, or `null` while it isn't one
|
|
3
|
+
* yet: empty field, a lone `"-"` before the digits of a negative number,
|
|
4
|
+
* `"1-2"`, a decimal, or a number too large to be represented exactly (beyond
|
|
5
|
+
* `Number.MAX_SAFE_INTEGER`). The caller then keeps its last valid value
|
|
6
|
+
* instead of replacing the text with `0`.
|
|
7
|
+
*
|
|
8
|
+
* @param raw - The field's text, ideally cleaned by `sanitizeIntegerInput`.
|
|
9
|
+
* @returns The integer, or `null`.
|
|
10
|
+
* @example
|
|
11
|
+
* parseIntegerInput("-12"); // => -12
|
|
12
|
+
* parseIntegerInput("-"); // => null
|
|
13
|
+
* parseIntegerInput(""); // => null
|
|
14
|
+
* parseIntegerInput("99999999999999999999"); // => null
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseIntegerInput(raw: string): number | null;
|
|
17
|
+
//# sourceMappingURL=parseIntegerInput.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseIntegerInput.d.ts","sourceRoot":"","sources":["../../src/number/parseIntegerInput.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO5D"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole number a field's text stands for, or `null` while it isn't one
|
|
3
|
+
* yet: empty field, a lone `"-"` before the digits of a negative number,
|
|
4
|
+
* `"1-2"`, a decimal, or a number too large to be represented exactly (beyond
|
|
5
|
+
* `Number.MAX_SAFE_INTEGER`). The caller then keeps its last valid value
|
|
6
|
+
* instead of replacing the text with `0`.
|
|
7
|
+
*
|
|
8
|
+
* @param raw - The field's text, ideally cleaned by `sanitizeIntegerInput`.
|
|
9
|
+
* @returns The integer, or `null`.
|
|
10
|
+
* @example
|
|
11
|
+
* parseIntegerInput("-12"); // => -12
|
|
12
|
+
* parseIntegerInput("-"); // => null
|
|
13
|
+
* parseIntegerInput(""); // => null
|
|
14
|
+
* parseIntegerInput("99999999999999999999"); // => null
|
|
15
|
+
*/
|
|
16
|
+
export function parseIntegerInput(raw) {
|
|
17
|
+
if (!/^-?\d+$/.test(raw))
|
|
18
|
+
return null;
|
|
19
|
+
const value = Number.parseInt(raw, 10);
|
|
20
|
+
if (!Number.isSafeInteger(value))
|
|
21
|
+
return null;
|
|
22
|
+
// "-0" would otherwise give a negative zero, shown back as "0" but unequal
|
|
23
|
+
// to 0 under Object.is (React's state comparison).
|
|
24
|
+
return Object.is(value, -0) ? 0 : value;
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface SanitizeIntegerInputOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Keep a leading minus sign.
|
|
4
|
+
*
|
|
5
|
+
* @defaultValue true
|
|
6
|
+
*/
|
|
7
|
+
allowNegative?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Cleans what the user is typing in a whole-number field: drops every
|
|
11
|
+
* character that is neither a digit nor a leading minus sign (spaces, commas,
|
|
12
|
+
* dots, pasted text…), keystroke by keystroke. A lone `"-"` survives, so a
|
|
13
|
+
* negative number can be typed normally — {@link parseIntegerInput} then tells
|
|
14
|
+
* whether the text is a number yet.
|
|
15
|
+
*
|
|
16
|
+
* @param raw - The field's text, as `onChangeText` gives it.
|
|
17
|
+
* @param options - See {@link SanitizeIntegerInputOptions}.
|
|
18
|
+
* @returns The text with only digits, and a minus sign in first position if allowed.
|
|
19
|
+
* @example
|
|
20
|
+
* sanitizeIntegerInput("1 250"); // => "1250"
|
|
21
|
+
* sanitizeIntegerInput("-"); // => "-"
|
|
22
|
+
* sanitizeIntegerInput("12-3"); // => "123"
|
|
23
|
+
* sanitizeIntegerInput("-4", { allowNegative: false }); // => "4"
|
|
24
|
+
*/
|
|
25
|
+
export declare function sanitizeIntegerInput(raw: string, { allowNegative }?: SanitizeIntegerInputOptions): string;
|
|
26
|
+
//# sourceMappingURL=sanitizeIntegerInput.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitizeIntegerInput.d.ts","sourceRoot":"","sources":["../../src/number/sanitizeIntegerInput.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,2BAA2B;IAC1C;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,EACX,EAAE,aAAoB,EAAE,GAAE,2BAAgC,GACzD,MAAM,CAKR"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleans what the user is typing in a whole-number field: drops every
|
|
3
|
+
* character that is neither a digit nor a leading minus sign (spaces, commas,
|
|
4
|
+
* dots, pasted text…), keystroke by keystroke. A lone `"-"` survives, so a
|
|
5
|
+
* negative number can be typed normally — {@link parseIntegerInput} then tells
|
|
6
|
+
* whether the text is a number yet.
|
|
7
|
+
*
|
|
8
|
+
* @param raw - The field's text, as `onChangeText` gives it.
|
|
9
|
+
* @param options - See {@link SanitizeIntegerInputOptions}.
|
|
10
|
+
* @returns The text with only digits, and a minus sign in first position if allowed.
|
|
11
|
+
* @example
|
|
12
|
+
* sanitizeIntegerInput("1 250"); // => "1250"
|
|
13
|
+
* sanitizeIntegerInput("-"); // => "-"
|
|
14
|
+
* sanitizeIntegerInput("12-3"); // => "123"
|
|
15
|
+
* sanitizeIntegerInput("-4", { allowNegative: false }); // => "4"
|
|
16
|
+
*/
|
|
17
|
+
export function sanitizeIntegerInput(raw, { allowNegative = true } = {}) {
|
|
18
|
+
const digits = raw.replace(/\D/g, "");
|
|
19
|
+
return allowNegative && raw.trimStart().startsWith("-")
|
|
20
|
+
? `-${digits}`
|
|
21
|
+
: digits;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Where the failure counter is kept — e.g. expo-secure-store on Android. */
|
|
2
|
+
export interface AttemptLimiterStorage {
|
|
3
|
+
getItem: (key: string) => Promise<string | null>;
|
|
4
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
5
|
+
deleteItem: (key: string) => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface AttemptLimiterOptions {
|
|
8
|
+
storage: AttemptLimiterStorage;
|
|
9
|
+
/** Storage key of the failure counter. */
|
|
10
|
+
key: string;
|
|
11
|
+
/** Failures allowed before any delay. @defaultValue 4 */
|
|
12
|
+
freeAttempts?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Delay imposed after the first failure past `freeAttempts`, the second…
|
|
15
|
+
* the last one repeating for every failure after.
|
|
16
|
+
* @defaultValue 30 s, 1 min, 5 min, 15 min
|
|
17
|
+
*/
|
|
18
|
+
delaysMs?: readonly number[];
|
|
19
|
+
/** @defaultValue `Date.now` */
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
export type AttemptResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
lockedUntil: number;
|
|
27
|
+
};
|
|
28
|
+
export interface AttemptLimiter {
|
|
29
|
+
/**
|
|
30
|
+
* Runs `verify` (e.g. compares a PIN) unless a delay is running, and
|
|
31
|
+
* updates the counter: reset on success, one more failure otherwise.
|
|
32
|
+
* `lockedUntil` is the end of the delay now imposed (0 for none). Attempts
|
|
33
|
+
* run one at a time, so parallel ones can't lose a failure.
|
|
34
|
+
*/
|
|
35
|
+
attempt: (verify: () => boolean | Promise<boolean>) => Promise<AttemptResult>;
|
|
36
|
+
/** End of the running delay (a timestamp), or 0 if an attempt is allowed. */
|
|
37
|
+
getLockedUntil: () => Promise<number>;
|
|
38
|
+
/** Forgets every failure (e.g. once the PIN is reset another way). */
|
|
39
|
+
reset: () => Promise<void>;
|
|
40
|
+
/** The delay imposed after `count` consecutive failures (0 for none). */
|
|
41
|
+
delayAfter: (count: number) => number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Limits attempts at a secret (a PIN, a passphrase) with growing delays:
|
|
45
|
+
* after `freeAttempts` failures, each new one imposes a wait. The counter is
|
|
46
|
+
* persisted through `storage`, so closing the app doesn't reset it, and an
|
|
47
|
+
* unreadable value is never taken as "no failure" (tampering with it would
|
|
48
|
+
* otherwise lift the limit): the next failure is then delayed at once. A
|
|
49
|
+
* last failure dated in the future (the clock was set back) restarts the
|
|
50
|
+
* delay from now instead of locking until that date.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* const pinLock = createAttemptLimiter({
|
|
54
|
+
* storage: {
|
|
55
|
+
* getItem: SecureStore.getItemAsync,
|
|
56
|
+
* setItem: SecureStore.setItemAsync,
|
|
57
|
+
* deleteItem: SecureStore.deleteItemAsync,
|
|
58
|
+
* },
|
|
59
|
+
* key: "pin-failures",
|
|
60
|
+
* });
|
|
61
|
+
* const result = await pinLock.attempt(() => isPinCorrect(typedPin));
|
|
62
|
+
* if (!result.ok && result.lockedUntil) showWaitUntil(result.lockedUntil);
|
|
63
|
+
*/
|
|
64
|
+
export declare function createAttemptLimiter({ storage, key, freeAttempts, delaysMs, now, }: AttemptLimiterOptions): AttemptLimiter;
|
|
65
|
+
//# sourceMappingURL=createAttemptLimiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createAttemptLimiter.d.ts","sourceRoot":"","sources":["../../src/security/createAttemptLimiter.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjD,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,qBAAqB,CAAC;IAC/B,0CAA0C;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,+BAA+B;IAC/B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;IAC9E,6EAA6E;IAC7E,cAAc,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACtC,sEAAsE;IACtE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,yEAAyE;IACzE,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAUD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,OAAO,EACP,GAAG,EACH,YAAgB,EAChB,QAA4B,EAC5B,GAAc,GACf,EAAE,qBAAqB,GAAG,cAAc,CA6ExC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const DEFAULT_DELAYS_MS = [30_000, 60_000, 5 * 60_000, 15 * 60_000];
|
|
2
|
+
const MAX_COUNT = 1_000_000;
|
|
3
|
+
/**
|
|
4
|
+
* Limits attempts at a secret (a PIN, a passphrase) with growing delays:
|
|
5
|
+
* after `freeAttempts` failures, each new one imposes a wait. The counter is
|
|
6
|
+
* persisted through `storage`, so closing the app doesn't reset it, and an
|
|
7
|
+
* unreadable value is never taken as "no failure" (tampering with it would
|
|
8
|
+
* otherwise lift the limit): the next failure is then delayed at once. A
|
|
9
|
+
* last failure dated in the future (the clock was set back) restarts the
|
|
10
|
+
* delay from now instead of locking until that date.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const pinLock = createAttemptLimiter({
|
|
14
|
+
* storage: {
|
|
15
|
+
* getItem: SecureStore.getItemAsync,
|
|
16
|
+
* setItem: SecureStore.setItemAsync,
|
|
17
|
+
* deleteItem: SecureStore.deleteItemAsync,
|
|
18
|
+
* },
|
|
19
|
+
* key: "pin-failures",
|
|
20
|
+
* });
|
|
21
|
+
* const result = await pinLock.attempt(() => isPinCorrect(typedPin));
|
|
22
|
+
* if (!result.ok && result.lockedUntil) showWaitUntil(result.lockedUntil);
|
|
23
|
+
*/
|
|
24
|
+
export function createAttemptLimiter({ storage, key, freeAttempts = 4, delaysMs = DEFAULT_DELAYS_MS, now = Date.now, }) {
|
|
25
|
+
let queue = Promise.resolve();
|
|
26
|
+
function delayAfter(count) {
|
|
27
|
+
if (count <= freeAttempts || delaysMs.length === 0)
|
|
28
|
+
return 0;
|
|
29
|
+
const index = Math.min(count - freeAttempts - 1, delaysMs.length - 1);
|
|
30
|
+
return delaysMs[index] ?? 0;
|
|
31
|
+
}
|
|
32
|
+
async function readFailures() {
|
|
33
|
+
const raw = await storage.getItem(key);
|
|
34
|
+
if (raw === null || raw === "")
|
|
35
|
+
return { count: 0, lastFailureAt: 0 };
|
|
36
|
+
try {
|
|
37
|
+
const { count, lastFailureAt } = JSON.parse(raw);
|
|
38
|
+
if (typeof count === "number" &&
|
|
39
|
+
Number.isInteger(count) &&
|
|
40
|
+
count >= 0 &&
|
|
41
|
+
count <= MAX_COUNT &&
|
|
42
|
+
typeof lastFailureAt === "number" &&
|
|
43
|
+
Number.isFinite(lastFailureAt) &&
|
|
44
|
+
lastFailureAt >= 0) {
|
|
45
|
+
return { count, lastFailureAt };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Unreadable: handled below.
|
|
50
|
+
}
|
|
51
|
+
return { count: freeAttempts, lastFailureAt: 0 };
|
|
52
|
+
}
|
|
53
|
+
async function writeFailures(failures) {
|
|
54
|
+
await storage.setItem(key, JSON.stringify(failures));
|
|
55
|
+
}
|
|
56
|
+
async function lockedUntil() {
|
|
57
|
+
const failures = await readFailures();
|
|
58
|
+
const current = now();
|
|
59
|
+
if (failures.lastFailureAt > current) {
|
|
60
|
+
failures.lastFailureAt = current;
|
|
61
|
+
await writeFailures(failures);
|
|
62
|
+
}
|
|
63
|
+
const until = failures.lastFailureAt + delayAfter(failures.count);
|
|
64
|
+
return until > current ? until : 0;
|
|
65
|
+
}
|
|
66
|
+
function serialized(task) {
|
|
67
|
+
const run = queue.then(task, task);
|
|
68
|
+
queue = run.catch(() => undefined);
|
|
69
|
+
return run;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
delayAfter,
|
|
73
|
+
getLockedUntil: () => serialized(lockedUntil),
|
|
74
|
+
reset: () => serialized(() => storage.deleteItem(key)),
|
|
75
|
+
attempt: (verify) => serialized(async () => {
|
|
76
|
+
const locked = await lockedUntil();
|
|
77
|
+
if (locked)
|
|
78
|
+
return { ok: false, lockedUntil: locked };
|
|
79
|
+
if (await verify()) {
|
|
80
|
+
await storage.deleteItem(key);
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
const { count } = await readFailures();
|
|
84
|
+
const failures = {
|
|
85
|
+
count: Math.min(count + 1, MAX_COUNT),
|
|
86
|
+
lastFailureAt: now(),
|
|
87
|
+
};
|
|
88
|
+
await writeFailures(failures);
|
|
89
|
+
const delay = delayAfter(failures.count);
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
lockedUntil: delay ? failures.lastFailureAt + delay : 0,
|
|
93
|
+
};
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/security/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,yBAAyB,CAAC;AACxC,mBAAmB,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { StandardSchemaIssue, StandardSchemaV1 } from "./standardSchema.js";
|
|
2
|
+
/** Why {@link parseUntrustedJson} refused a text. */
|
|
3
|
+
export type UntrustedJsonFailure = "empty" | "too-long" | "malformed" | "invalid";
|
|
4
|
+
export type UntrustedJsonResult<T> = {
|
|
5
|
+
ok: true;
|
|
6
|
+
data: T;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: UntrustedJsonFailure;
|
|
10
|
+
/** A message ready to show the user (French by default). */
|
|
11
|
+
error: string;
|
|
12
|
+
/** First schema issue's path, keys as given (`["players", 2, "name"]`). */
|
|
13
|
+
path?: PropertyKey[];
|
|
14
|
+
/** Every issue the schema reported (`reason: "invalid"` only). */
|
|
15
|
+
issues?: readonly StandardSchemaIssue[];
|
|
16
|
+
/**
|
|
17
|
+
* The parsed but **unvalidated** value (`reason: "invalid"` only) — e.g.
|
|
18
|
+
* to read a `version` field and say "update the app". Never use it as data.
|
|
19
|
+
*/
|
|
20
|
+
raw?: unknown;
|
|
21
|
+
};
|
|
22
|
+
export interface UntrustedJsonMessages {
|
|
23
|
+
empty: string;
|
|
24
|
+
tooLong: string;
|
|
25
|
+
malformed: string;
|
|
26
|
+
/** `where` is the described path, `""` for the root value. */
|
|
27
|
+
invalid: (where: string) => string;
|
|
28
|
+
}
|
|
29
|
+
export interface ParseUntrustedJsonOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Longest text accepted, in characters, checked **before** `JSON.parse`: a
|
|
32
|
+
* huge file is refused without being parsed. Set it a little above the
|
|
33
|
+
* largest legitimate content.
|
|
34
|
+
*
|
|
35
|
+
* @defaultValue 1_000_000
|
|
36
|
+
*/
|
|
37
|
+
maxLength?: number;
|
|
38
|
+
/** Language of the default messages. @defaultValue "fr" */
|
|
39
|
+
locale?: "fr" | "en";
|
|
40
|
+
/** Overrides some or all of the messages. */
|
|
41
|
+
messages?: Partial<UntrustedJsonMessages>;
|
|
42
|
+
/**
|
|
43
|
+
* Turns an issue path into words for `messages.invalid`, e.g. with a map of
|
|
44
|
+
* field names. Default: keys joined with " › ", indexes counted from 1.
|
|
45
|
+
*/
|
|
46
|
+
describePath?: (path: PropertyKey[]) => string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Reads JSON that came from outside the app (an imported file, pasted text, a
|
|
50
|
+
* QR code…) and validates it with a schema, all or nothing: the first problem
|
|
51
|
+
* rejects the whole text, so nothing is ever half-imported.
|
|
52
|
+
*
|
|
53
|
+
* In order: empty text → too long (before parsing, so a huge file costs
|
|
54
|
+
* nothing) → malformed JSON → `__proto__` / `constructor` / `prototype` keys
|
|
55
|
+
* dropped → schema. With zod, unknown keys are also stripped by `z.object`
|
|
56
|
+
* (use bounded types: `.max()` on strings and arrays, `.int().min().max()` on
|
|
57
|
+
* numbers, enums). Synchronous schemas only.
|
|
58
|
+
*
|
|
59
|
+
* @param text - The untrusted text.
|
|
60
|
+
* @param schema - Any Standard Schema (zod, Valibot, ArkType…).
|
|
61
|
+
* @param options - See {@link ParseUntrustedJsonOptions}.
|
|
62
|
+
* @returns `{ ok: true, data }`, or `{ ok: false, reason, error, … }` with a
|
|
63
|
+
* message ready to show.
|
|
64
|
+
* @example
|
|
65
|
+
* const result = parseUntrustedJson(text, backupSchema, { maxLength: 200_000 });
|
|
66
|
+
* if (!result.ok) return Alert.alert("Import impossible", result.error);
|
|
67
|
+
* restore(result.data);
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseUntrustedJson<T>(text: string, schema: StandardSchemaV1<T>, options?: ParseUntrustedJsonOptions): UntrustedJsonResult<T>;
|
|
70
|
+
//# sourceMappingURL=parseUntrustedJson.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseUntrustedJson.d.ts","sourceRoot":"","sources":["../../src/security/parseUntrustedJson.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,mBAAmB,EAEnB,gBAAgB,EACjB,MAAM,qBAAqB,CAAC;AAE7B,qDAAqD;AACrD,MAAM,MAAM,oBAAoB,GAC9B,OAAO,GAAG,UAAU,GAAG,WAAW,GAAG,SAAS,CAAC;AAEjD,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,CAAC,CAAA;CAAE,GACrB;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,oBAAoB,CAAC;IAC7B,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,IAAI,CAAC,EAAE,WAAW,EAAE,CAAC;IACrB,kEAAkE;IAClE,MAAM,CAAC,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACxC;;;OAGG;IACH,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AAEN,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CACpC;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACrB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC1C;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,MAAM,CAAC;CAChD;AAqDD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAClC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,GAAE,yBAA8B,GACtC,mBAAmB,CAAC,CAAC,CAAC,CAyCxB"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const DEFAULT_MAX_LENGTH = 1_000_000;
|
|
2
|
+
const MESSAGES = {
|
|
3
|
+
fr: {
|
|
4
|
+
empty: "Le contenu est vide.",
|
|
5
|
+
tooLong: "Le contenu est trop volumineux. Rien n'a été importé.",
|
|
6
|
+
malformed: "Le contenu est illisible (JSON invalide). Rien n'a été importé.",
|
|
7
|
+
invalid: (where) => where
|
|
8
|
+
? `Contenu invalide : « ${where} » incorrect. Rien n'a été importé.`
|
|
9
|
+
: "Contenu invalide. Rien n'a été importé.",
|
|
10
|
+
},
|
|
11
|
+
en: {
|
|
12
|
+
empty: "The content is empty.",
|
|
13
|
+
tooLong: "The content is too large. Nothing was imported.",
|
|
14
|
+
malformed: "The content can't be read (invalid JSON). Nothing was imported.",
|
|
15
|
+
invalid: (where) => where
|
|
16
|
+
? `Invalid content: "${where}" is incorrect. Nothing was imported.`
|
|
17
|
+
: "Invalid content. Nothing was imported.",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
// Own properties with these names are harmless after JSON.parse, but turn
|
|
21
|
+
// into prototype changes once merged with `Object.assign` or a deep-merge
|
|
22
|
+
// helper: dropped while parsing, whatever the schema does next.
|
|
23
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
24
|
+
function stripForbiddenKeys(key, value) {
|
|
25
|
+
return FORBIDDEN_KEYS.has(key) ? undefined : value;
|
|
26
|
+
}
|
|
27
|
+
function toKey(segment) {
|
|
28
|
+
return typeof segment === "object" ? segment.key : segment;
|
|
29
|
+
}
|
|
30
|
+
function defaultDescribePath(locale) {
|
|
31
|
+
return (path) => path
|
|
32
|
+
.map((key) => typeof key === "number"
|
|
33
|
+
? locale === "fr"
|
|
34
|
+
? `n° ${key + 1}`
|
|
35
|
+
: `#${key + 1}`
|
|
36
|
+
: String(key))
|
|
37
|
+
.join(" › ");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads JSON that came from outside the app (an imported file, pasted text, a
|
|
41
|
+
* QR code…) and validates it with a schema, all or nothing: the first problem
|
|
42
|
+
* rejects the whole text, so nothing is ever half-imported.
|
|
43
|
+
*
|
|
44
|
+
* In order: empty text → too long (before parsing, so a huge file costs
|
|
45
|
+
* nothing) → malformed JSON → `__proto__` / `constructor` / `prototype` keys
|
|
46
|
+
* dropped → schema. With zod, unknown keys are also stripped by `z.object`
|
|
47
|
+
* (use bounded types: `.max()` on strings and arrays, `.int().min().max()` on
|
|
48
|
+
* numbers, enums). Synchronous schemas only.
|
|
49
|
+
*
|
|
50
|
+
* @param text - The untrusted text.
|
|
51
|
+
* @param schema - Any Standard Schema (zod, Valibot, ArkType…).
|
|
52
|
+
* @param options - See {@link ParseUntrustedJsonOptions}.
|
|
53
|
+
* @returns `{ ok: true, data }`, or `{ ok: false, reason, error, … }` with a
|
|
54
|
+
* message ready to show.
|
|
55
|
+
* @example
|
|
56
|
+
* const result = parseUntrustedJson(text, backupSchema, { maxLength: 200_000 });
|
|
57
|
+
* if (!result.ok) return Alert.alert("Import impossible", result.error);
|
|
58
|
+
* restore(result.data);
|
|
59
|
+
*/
|
|
60
|
+
export function parseUntrustedJson(text, schema, options = {}) {
|
|
61
|
+
const { maxLength = DEFAULT_MAX_LENGTH, locale = "fr", describePath = defaultDescribePath(locale), } = options;
|
|
62
|
+
const messages = { ...MESSAGES[locale], ...options.messages };
|
|
63
|
+
if (text.length > maxLength) {
|
|
64
|
+
return { ok: false, reason: "too-long", error: messages.tooLong };
|
|
65
|
+
}
|
|
66
|
+
const trimmed = text.trim();
|
|
67
|
+
if (trimmed.length === 0) {
|
|
68
|
+
return { ok: false, reason: "empty", error: messages.empty };
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = JSON.parse(trimmed, stripForbiddenKeys);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { ok: false, reason: "malformed", error: messages.malformed };
|
|
76
|
+
}
|
|
77
|
+
const result = schema["~standard"].validate(raw);
|
|
78
|
+
if (result instanceof Promise) {
|
|
79
|
+
throw new TypeError("parseUntrustedJson only supports synchronous schemas (no async refinements).");
|
|
80
|
+
}
|
|
81
|
+
if (result.issues) {
|
|
82
|
+
const path = (result.issues[0]?.path ?? []).map(toKey);
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: "invalid",
|
|
86
|
+
error: messages.invalid(describePath(path)),
|
|
87
|
+
path,
|
|
88
|
+
issues: result.issues,
|
|
89
|
+
raw,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, data: result.value };
|
|
93
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of the [Standard Schema](https://standardschema.dev) interface
|
|
3
|
+
* this package reads: implemented by zod (3.24+ and 4), Valibot, ArkType… so
|
|
4
|
+
* {@link parseUntrustedJson} takes any of them without depending on one.
|
|
5
|
+
*/
|
|
6
|
+
export interface StandardSchemaV1<Output = unknown> {
|
|
7
|
+
readonly "~standard": {
|
|
8
|
+
readonly version: 1;
|
|
9
|
+
readonly vendor: string;
|
|
10
|
+
readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** A path segment as schemas report it: a key, or `{ key }`. */
|
|
14
|
+
export type StandardSchemaPathSegment = PropertyKey | {
|
|
15
|
+
readonly key: PropertyKey;
|
|
16
|
+
};
|
|
17
|
+
export interface StandardSchemaIssue {
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly path?: readonly StandardSchemaPathSegment[];
|
|
20
|
+
}
|
|
21
|
+
export type StandardSchemaResult<Output> = {
|
|
22
|
+
readonly value: Output;
|
|
23
|
+
readonly issues?: undefined;
|
|
24
|
+
} | {
|
|
25
|
+
readonly issues: readonly StandardSchemaIssue[];
|
|
26
|
+
};
|
|
27
|
+
/** The output type of a Standard Schema (`z.infer` for a zod schema). */
|
|
28
|
+
export type InferSchemaOutput<S> = S extends StandardSchemaV1<infer O> ? O : never;
|
|
29
|
+
//# sourceMappingURL=standardSchema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"standardSchema.d.ts","sourceRoot":"","sources":["../../src/security/standardSchema.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,gBAAgB,CAAC,MAAM,GAAG,OAAO;IAChD,QAAQ,CAAC,WAAW,EAAE;QACpB,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,QAAQ,EAAE,CACjB,KAAK,EAAE,OAAO,KACX,oBAAoB,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,oBAAoB,CAAC,MAAM,CAAC,CAAC,CAAC;KAC3E,CAAC;CACH;AAED,gEAAgE;AAChE,MAAM,MAAM,yBAAyB,GACnC,WAAW,GAAG;IAAE,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAA;CAAE,CAAC;AAE9C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,yBAAyB,EAAE,CAAC;CACtD;AAED,MAAM,MAAM,oBAAoB,CAAC,MAAM,IACnC;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAA;CAAE,CAAC;AAExD,yEAAyE;AACzE,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAC7B,CAAC,SAAS,gBAAgB,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forthtilliath/ts-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
"./*": "./dist/*.js"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@vitest/coverage-v8": "5.0.
|
|
26
|
-
"eslint": "10.
|
|
27
|
-
"jsdom": "^30.1.
|
|
25
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
26
|
+
"eslint": "10.14.0",
|
|
27
|
+
"jsdom": "^30.1.3",
|
|
28
28
|
"typescript": "^5.9.3",
|
|
29
|
-
"vitest": "^5.0.
|
|
30
|
-
"
|
|
31
|
-
"@forthtilliath/eslint-config": "0.
|
|
29
|
+
"vitest": "^5.0.5",
|
|
30
|
+
"zod": "^4.6.7",
|
|
31
|
+
"@forthtilliath/eslint-config": "0.10.1",
|
|
32
|
+
"@forthtilliath/typescript-config": "0.1.1"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"dev": "tsc --watch -p tsconfig.build.json",
|