@aria-framework/kit 0.1.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 ADDED
@@ -0,0 +1,81 @@
1
+ # @aria-framework/kit
2
+
3
+ Aria App Framework — kit module. Five small, dependency-free server utilities
4
+ (Node builtins only, plain CommonJS, no build step).
5
+
6
+ ```js
7
+ const { safeReturnTo, escapeLike, fullName, splitName, fileSniff, trackingId }
8
+ = require('@aria-framework/kit');
9
+ ```
10
+
11
+ ## safeReturnTo — open-redirect guard
12
+
13
+ Validates a post-login `returnTo` path. Only on-site paths survive (absolute,
14
+ single leading slash, no scheme/host, no `//` protocol-relative trick);
15
+ anything else collapses to the fallback.
16
+
17
+ ```js
18
+ safeReturnTo(req.query.returnTo, { fallback: '/dashboard' });
19
+ safeReturnTo(rt, { fallback: '/', denyPrefix: '/portal' }); // also keep users out of auth pages
20
+ ```
21
+
22
+ ## escapeLike — SQL LIKE escaping
23
+
24
+ Escapes `%`, `_` and `\` so user input can sit inside a LIKE pattern. Pair
25
+ with `ESCAPE '\'` in the query:
26
+
27
+ ```js
28
+ db.prepare("... WHERE name LIKE ? ESCAPE '\\'").all(`%${escapeLike(q)}%`);
29
+ ```
30
+
31
+ ## fileSniff — magic-byte upload validation
32
+
33
+ The client-declared Content-Type is attacker-controlled; this checks the
34
+ file's actual leading bytes against the declared MIME's **container family**
35
+ (docx/xlsx are both ZIP, doc/xls both OLE — exact subtypes can't be told apart
36
+ by magic, so families are the honest granularity).
37
+
38
+ ```js
39
+ if (!fileSniff.matches(file.path, file.mimetype)) reject(file);
40
+ fileSniff.sniff(path); // → 'image/png' | 'zip' | 'ole' | 'isobmff' | 'text' | ... | null
41
+ ```
42
+
43
+ Covers png/jpeg/gif/webp, pdf, text/csv, office (old + OOXML), heic/heif,
44
+ mp4/mov/webm/avi/ogg, mp3/m4a/wav, zip/7z/rar/gzip.
45
+
46
+ ## trackingId — customer-facing reference codes
47
+
48
+ `AB12-CD34-EF56` — Crockford base32 (no I, L, O, U: these codes get read
49
+ aloud and retyped), `crypto.randomInt` for unbiased unguessable picks,
50
+ 60 bits of space.
51
+
52
+ ```js
53
+ trackingId.generate(); // random, no collision check
54
+ trackingId.generateUnique((id) => // guaranteed unique per YOUR storage
55
+ db.prepare('SELECT 1 FROM tickets WHERE tracking_id = ?').get(id));
56
+ ```
57
+
58
+ `generateUnique` is storage-agnostic — pass any `(id) => truthy-if-exists`
59
+ check (SQL, ORM, HTTP, in-memory). Throws after `maxAttempts` (default 10).
60
+
61
+ ## fullName / splitName — person-name convention helpers
62
+
63
+ For the first_name + last_name model where `display_name`/`name` is a
64
+ **maintained composite** (never hand-written):
65
+
66
+ ```js
67
+ fullName('Ann', 'Bee'); // 'Ann Bee' (trims, drops blanks)
68
+ splitName('Johan van der Merwe') // { first_name: 'Johan van der', last_name: 'Merwe' }
69
+ ```
70
+
71
+ `splitName` splits on the **last** space (last word = surname). Compound
72
+ surnames split imperfectly by design. If an app backfills a split in SQL,
73
+ implement the same last-space rule and keep them in step. Also exported
74
+ namespaced as `personName.{fullName,splitName}`.
75
+
76
+ ## Changelog
77
+
78
+ - **0.1.0** — first release. Extracted from Support101 `lib/` (safeReturnTo,
79
+ likeEscape, fileSniff, trackingId, personName). One API change vs the app
80
+ originals: `trackingId.generateUnique` takes an `isTaken(id)` callback
81
+ instead of a better-sqlite3 handle + hardcoded tickets table.
package/fileSniff.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Magic-byte file-type validation for uploads.
3
+ *
4
+ * multer's `file.mimetype` is the client-declared Content-Type — attacker
5
+ * controlled. This module reads the actual leading bytes and checks they are
6
+ * consistent with the declared MIME's container family, so an HTML file
7
+ * labelled `image/png` is rejected. Office formats can't be distinguished from
8
+ * each other by magic alone (docx/xlsx are both ZIP; doc/xls are both OLE), so
9
+ * we validate the container family, not the exact subtype.
10
+ */
11
+
12
+ const fs = require('fs');
13
+
14
+ /** Sniff the container/type from the first bytes. Returns a coarse tag or null. */
15
+ function sniff(filePath) {
16
+ let fd;
17
+ try {
18
+ fd = fs.openSync(filePath, 'r');
19
+ const buf = Buffer.alloc(16);
20
+ const n = fs.readSync(fd, buf, 0, 16, 0);
21
+ const b = buf.subarray(0, n);
22
+ const hex = b.toString('hex').toUpperCase();
23
+
24
+ if (hex.startsWith('89504E47')) return 'image/png';
25
+ if (hex.startsWith('FFD8FF')) return 'image/jpeg';
26
+ if (hex.startsWith('47494638')) return 'image/gif';
27
+ if (hex.startsWith('25504446')) return 'application/pdf';
28
+
29
+ // RIFF container — disambiguate by the form-type at bytes 8–12.
30
+ if (hex.startsWith('52494646') && n >= 12) {
31
+ const form = b.subarray(8, 12).toString('ascii');
32
+ if (form === 'WEBP') return 'image/webp';
33
+ if (form === 'WAVE') return 'riff-wave';
34
+ if (form === 'AVI ') return 'riff-avi';
35
+ }
36
+
37
+ // ISO-BMFF (MP4/MOV/M4A/HEIC): 'ftyp' box at bytes 4–8. We don't split the
38
+ // brand — validate the container family, like we do for zip-based formats.
39
+ if (n >= 8 && b.subarray(4, 8).toString('ascii') === 'ftyp') return 'isobmff';
40
+ // Matroska/WebM (EBML)
41
+ if (hex.startsWith('1A45DFA3')) return 'ebml';
42
+ // Ogg (audio/video)
43
+ if (hex.startsWith('4F676753')) return 'ogg';
44
+ // MP3: ID3 tag, or an MPEG audio frame sync (0xFFEx).
45
+ if (hex.startsWith('494433') || (n >= 2 && b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) return 'mp3';
46
+
47
+ // ZIP container (docx/xlsx and plain zip): PK\x03\x04 / PK\x05\x06 / PK\x07\x08
48
+ if (hex.startsWith('504B0304') || hex.startsWith('504B0506') || hex.startsWith('504B0708')) return 'zip';
49
+ // OLE compound file (legacy doc/xls)
50
+ if (hex.startsWith('D0CF11E0A1B11AE1')) return 'ole';
51
+ // Other archives
52
+ if (hex.startsWith('377ABCAF271C')) return '7z';
53
+ if (hex.startsWith('526172211A07')) return 'rar'; // 'Rar!\x1A\x07'
54
+ if (hex.startsWith('1F8B')) return 'gzip';
55
+ // No binary signature: treat as text only if there are no NUL bytes.
56
+ if (n > 0 && !b.includes(0)) return 'text';
57
+ return null;
58
+ } catch (_) {
59
+ return null;
60
+ } finally {
61
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
62
+ }
63
+ }
64
+
65
+ // Declared MIME → the sniffed tags that are acceptable for it.
66
+ const FAMILY = {
67
+ 'image/png': ['image/png'],
68
+ 'image/jpeg': ['image/jpeg'],
69
+ 'image/gif': ['image/gif'],
70
+ 'image/webp': ['image/webp'],
71
+ 'application/pdf': ['application/pdf'],
72
+ 'text/plain': ['text'],
73
+ 'text/csv': ['text'],
74
+ 'application/msword': ['ole'],
75
+ 'application/vnd.ms-excel': ['ole'],
76
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['zip'],
77
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['zip'],
78
+ // images (HEIC/HEIF are ISO-BMFF)
79
+ 'image/heic': ['isobmff'],
80
+ 'image/heif': ['isobmff'],
81
+ // video
82
+ 'video/mp4': ['isobmff'],
83
+ 'video/quicktime': ['isobmff'],
84
+ 'video/webm': ['ebml'],
85
+ 'video/x-msvideo': ['riff-avi'],
86
+ 'video/ogg': ['ogg'],
87
+ // audio
88
+ 'audio/mpeg': ['mp3'],
89
+ 'audio/mp4': ['isobmff'],
90
+ 'audio/x-m4a': ['isobmff'],
91
+ 'audio/wav': ['riff-wave'],
92
+ 'audio/x-wav': ['riff-wave'],
93
+ 'audio/ogg': ['ogg'],
94
+ 'audio/webm': ['ebml'],
95
+ // archives
96
+ 'application/zip': ['zip'],
97
+ 'application/x-zip-compressed': ['zip'],
98
+ 'application/x-7z-compressed': ['7z'],
99
+ 'application/x-rar-compressed': ['rar'],
100
+ 'application/vnd.rar': ['rar'],
101
+ 'application/gzip': ['gzip']
102
+ };
103
+
104
+ /**
105
+ * True if the file's actual content is consistent with the declared MIME.
106
+ * @param {string} filePath
107
+ * @param {string} declaredMime
108
+ * @returns {boolean}
109
+ */
110
+ function matches(filePath, declaredMime) {
111
+ const allowed = FAMILY[declaredMime];
112
+ if (!allowed) return false;
113
+ const sniffed = sniff(filePath);
114
+ return !!sniffed && allowed.includes(sniffed);
115
+ }
116
+
117
+ module.exports = { sniff, matches };
package/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @aria-framework/kit — public API.
3
+ *
4
+ * Single-function modules are exported flat; multi-function modules keep a
5
+ * namespace (matching how call sites naturally read):
6
+ *
7
+ * safeReturnTo(returnTo, {fallback, denyPrefix}) — open-redirect guard
8
+ * escapeLike(s) — SQL LIKE escaping
9
+ * fullName(first, last) / splitName(full) — person-name helpers
10
+ * fileSniff.sniff(path) / fileSniff.matches(path, mime)
11
+ * trackingId.generate() / trackingId.generateUnique(isTaken, maxAttempts)
12
+ */
13
+
14
+ const { escapeLike } = require('./likeEscape');
15
+ const { fullName, splitName } = require('./personName');
16
+
17
+ module.exports = {
18
+ safeReturnTo: require('./safeReturnTo'),
19
+ escapeLike,
20
+ fullName,
21
+ splitName,
22
+ fileSniff: require('./fileSniff'),
23
+ trackingId: require('./trackingId'),
24
+ personName: { fullName, splitName }
25
+ };
package/likeEscape.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Escape a user-supplied string for safe use inside a SQL LIKE pattern.
3
+ * Without this, a search for "100%" or "_" would match far more than intended
4
+ * (a full-table scan / wrong results). Pair with `ESCAPE '\'` in the query.
5
+ *
6
+ * @param {string} s
7
+ * @returns {string}
8
+ */
9
+ function escapeLike(s) {
10
+ return String(s == null ? '' : s).replace(/[%_\\]/g, '\\$&');
11
+ }
12
+
13
+ module.exports = { escapeLike };
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@aria-framework/kit",
3
+ "version": "0.1.0",
4
+ "description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "index.js",
11
+ "files": [
12
+ "index.js",
13
+ "safeReturnTo.js",
14
+ "likeEscape.js",
15
+ "fileSniff.js",
16
+ "trackingId.js",
17
+ "personName.js"
18
+ ]
19
+ }
package/personName.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Canonical person-name helpers — one source of truth for composing a full
3
+ * display name from first + last, and for splitting a full name back into the
4
+ * two parts. Use these everywhere a first_name/last_name pair meets a
5
+ * display_name/full-name field, so the composite convention can't drift.
6
+ *
7
+ * If an app backfills a split in SQL (one-time migration), implement the SAME
8
+ * last-space split there and keep it in step with splitName(). Separate
9
+ * runtime bundles (e.g. a React Native app) need their own copy — keep those
10
+ * in step too.
11
+ */
12
+ 'use strict';
13
+
14
+ /** Compose a full/display name from first + last (trimmed; blank parts dropped). */
15
+ function fullName(first, last) {
16
+ return [first, last].map(v => String(v || '').trim()).filter(Boolean).join(' ');
17
+ }
18
+
19
+ /**
20
+ * Split a full name on the LAST space: the last word is the surname, everything
21
+ * before it is the first name(s). No space → the whole value is the first name.
22
+ * Compound surnames split imperfectly by design ("Johan van der Merwe"
23
+ * → first "Johan van der" / last "Merwe").
24
+ */
25
+ function splitName(full) {
26
+ const s = String(full || '').trim();
27
+ const i = s.lastIndexOf(' ');
28
+ if (i === -1) return { first_name: s, last_name: '' };
29
+ return { first_name: s.slice(0, i).trim(), last_name: s.slice(i + 1).trim() };
30
+ }
31
+
32
+ module.exports = { fullName, splitName };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Validate a post-login returnTo path against open-redirect abuse.
3
+ *
4
+ * A safe path is absolute, single-slash, and on-site (no scheme/host, no `//`
5
+ * protocol-relative prefix). Anything else collapses to `fallback`. Keep the
6
+ * parsing/normalisation in ONE place — a hardening fix here covers every login
7
+ * flow. Typical usage divergence is just data:
8
+ * - staff login: safeReturnTo(rt, { fallback: '/dashboard' })
9
+ * - portal login: safeReturnTo(rt, { fallback: '/', denyPrefix: '/portal' })
10
+ *
11
+ * @param {string} returnTo
12
+ * @param {{fallback?: string, denyPrefix?: string}} [opts]
13
+ * @returns {string} a safe on-site path, or the fallback
14
+ */
15
+
16
+ // Sentinel base host for relative-URL parsing; any input that resolves to a
17
+ // DIFFERENT host smuggled in its own authority and is rejected.
18
+ const SENTINEL = '_aria_kit_local';
19
+
20
+ function safeReturnTo(returnTo, opts = {}) {
21
+ const fallback = opts.fallback || '/';
22
+ if (typeof returnTo !== 'string' || !returnTo) return fallback;
23
+ try {
24
+ const url = new URL(returnTo, `http://${SENTINEL}`);
25
+ if (url.host !== SENTINEL) return fallback;
26
+ if (!url.pathname.startsWith('/') || url.pathname.startsWith('//')) return fallback;
27
+ // Never bounce a freshly-logged-in user back into the auth pages.
28
+ if (opts.denyPrefix && url.pathname.startsWith(opts.denyPrefix)) return fallback;
29
+ return url.pathname + url.search;
30
+ } catch {
31
+ return fallback;
32
+ }
33
+ }
34
+
35
+ module.exports = safeReturnTo;
package/trackingId.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public tracking-ID generator (tickets, orders, quotes — any customer-facing
3
+ * reference).
4
+ *
5
+ * Format: three groups of 4 Crockford base32 characters, e.g. "AB12-CD34-EF56".
6
+ * Crockford base32 omits I, L, O, U to avoid visual/voice ambiguity — these
7
+ * IDs get read aloud and retyped by customers. crypto.randomInt gives an
8
+ * unbiased pick from the 32-char alphabet.
9
+ *
10
+ * 12 chars × 5 bits = 60 bits of space, so collisions are astronomically rare;
11
+ * generateUnique still checks on insert to make uniqueness a guarantee, not a
12
+ * probability.
13
+ */
14
+
15
+ const crypto = require('crypto');
16
+
17
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford base32 (no I L O U)
18
+
19
+ function group() {
20
+ let s = '';
21
+ for (let i = 0; i < 4; i++) s += ALPHABET[crypto.randomInt(ALPHABET.length)];
22
+ return s;
23
+ }
24
+
25
+ /** Generate a random tracking ID (no collision check). */
26
+ function generate() {
27
+ return `${group()}-${group()}-${group()}`;
28
+ }
29
+
30
+ /**
31
+ * Generate a tracking ID guaranteed unique per the caller's own storage.
32
+ * Storage-agnostic: the caller supplies the existence check.
33
+ *
34
+ * trackingId.generateUnique((id) =>
35
+ * db.prepare('SELECT 1 FROM tickets WHERE tracking_id = ?').get(id));
36
+ *
37
+ * @param {(id: string) => any} isTaken - truthy if the ID already exists
38
+ * @param {number} [maxAttempts=10]
39
+ * @returns {string}
40
+ */
41
+ function generateUnique(isTaken, maxAttempts = 10) {
42
+ if (typeof isTaken !== 'function') {
43
+ throw new Error('generateUnique(isTaken): isTaken must be a function (id) => truthy-if-exists');
44
+ }
45
+ for (let i = 0; i < maxAttempts; i++) {
46
+ const id = generate();
47
+ if (!isTaken(id)) return id;
48
+ }
49
+ throw new Error('Could not generate a unique tracking ID');
50
+ }
51
+
52
+ module.exports = { generate, generateUnique };