@heavybit/pendoadmin-shared-lib 1.4.0 → 1.6.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/dist/common/index.d.ts +2 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/common/index.js +2 -0
- package/dist/common/index.js.map +1 -1
- package/dist/common/permission.utils.d.ts +11 -0
- package/dist/common/permission.utils.d.ts.map +1 -0
- package/dist/common/permission.utils.js +16 -0
- package/dist/common/permission.utils.js.map +1 -0
- package/dist/common/timezone.utils.d.ts +41 -0
- package/dist/common/timezone.utils.d.ts.map +1 -0
- package/dist/common/timezone.utils.js +106 -0
- package/dist/common/timezone.utils.js.map +1 -0
- package/dist/express/permission.guard.d.ts.map +1 -1
- package/dist/express/permission.guard.js +2 -5
- package/dist/express/permission.guard.js.map +1 -1
- package/package.json +3 -2
- package/src/common/index.ts +2 -0
- package/src/common/permission.utils.ts +18 -0
- package/src/common/timezone.utils.ts +137 -0
- package/src/express/permission.guard.ts +2 -5
- package/tests/permission.utils.test.mjs +59 -0
- package/tests/timezone.utils.test.mjs +141 -0
package/dist/common/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC"}
|
package/dist/common/index.js
CHANGED
package/dist/common/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IAuthUser } from '../types/index.js';
|
|
2
|
+
/** The API-key scope that grants every permission the key's company has. */
|
|
3
|
+
export declare const ALL_PERMISSIONS_SCOPE = "*";
|
|
4
|
+
export type PermissionHolder = Pick<IAuthUser, 'isSuperAdmin' | 'permissions'>;
|
|
5
|
+
/**
|
|
6
|
+
* True when the user may act with ANY of the listed permissions: super admins always,
|
|
7
|
+
* API keys holding the '*' scope always, otherwise when one of the permissions is granted.
|
|
8
|
+
* This is the rule `requirePermission` enforces; use it for in-service checks so the two never drift.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hasPermission(user: PermissionHolder | null | undefined, ...permissions: string[]): boolean;
|
|
11
|
+
//# sourceMappingURL=permission.utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission.utils.d.ts","sourceRoot":"","sources":["../../src/common/permission.utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEnD,4EAA4E;AAC5E,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,MAAM,MAAM,gBAAgB,GAAG,IAAI,CAAC,SAAS,EAAE,cAAc,GAAG,aAAa,CAAC,CAAC;AAE/E;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAAE,GAAG,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAK1G"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The API-key scope that grants every permission the key's company has. */
|
|
2
|
+
export const ALL_PERMISSIONS_SCOPE = '*';
|
|
3
|
+
/**
|
|
4
|
+
* True when the user may act with ANY of the listed permissions: super admins always,
|
|
5
|
+
* API keys holding the '*' scope always, otherwise when one of the permissions is granted.
|
|
6
|
+
* This is the rule `requirePermission` enforces; use it for in-service checks so the two never drift.
|
|
7
|
+
*/
|
|
8
|
+
export function hasPermission(user, ...permissions) {
|
|
9
|
+
if (!user)
|
|
10
|
+
return false;
|
|
11
|
+
if (user.isSuperAdmin)
|
|
12
|
+
return true;
|
|
13
|
+
const granted = user.permissions ?? [];
|
|
14
|
+
return granted.includes(ALL_PERMISSIONS_SCOPE) || permissions.some(p => granted.includes(p));
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=permission.utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission.utils.js","sourceRoot":"","sources":["../../src/common/permission.utils.ts"],"names":[],"mappings":"AAEA,4EAA4E;AAC5E,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAIzC;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,IAAyC,EAAE,GAAG,WAAqB;IAC/F,IAAI,CAAC,IAAI;QAAE,OAAO,KAAK,CAAC;IACxB,IAAI,IAAI,CAAC,YAAY;QAAE,OAAO,IAAI,CAAC;IACnC,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,IAAI,EAAE,CAAC;IACvC,OAAO,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAC,IAAI,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AAC/F,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IANA timezone helpers.
|
|
3
|
+
*
|
|
4
|
+
* The database stores UTC; these helpers convert only at display time using
|
|
5
|
+
* the zone's own DST rules via Intl. Never pass a fixed offset ("+03:00",
|
|
6
|
+
* "GMT+3") as a timezone — those are rejected.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Canonical casing of a valid IANA zone (e.g. "africa/nairobi" → "Africa/Nairobi"),
|
|
10
|
+
* or null when the value is not a valid IANA zone.
|
|
11
|
+
*/
|
|
12
|
+
export declare function normalizeTimezone(tz: unknown): string | null;
|
|
13
|
+
/** True when `tz` is an IANA timezone identifier Intl can format with. */
|
|
14
|
+
export declare function isValidIanaTimezone(tz: unknown): tz is string;
|
|
15
|
+
/**
|
|
16
|
+
* Platform default for users whose timezone has never been observed.
|
|
17
|
+
* Overridable with DEFAULT_USER_TIMEZONE; an invalid override is ignored.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_TIMEZONE: string;
|
|
20
|
+
/** Returns `tz` (canonicalised) when valid, otherwise `fallback`. Never throws. */
|
|
21
|
+
export declare function resolveTimezone(tz: unknown, fallback?: string): string;
|
|
22
|
+
export interface FormatInTimezoneOptions {
|
|
23
|
+
/** Zone used when `tz` is missing or invalid. Defaults to DEFAULT_TIMEZONE. */
|
|
24
|
+
fallback?: string;
|
|
25
|
+
/** 12-hour clock ("2:05 PM") instead of the default 24-hour ("14:05"). */
|
|
26
|
+
hour12?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface FormattedInTimezone {
|
|
29
|
+
/** e.g. "26 Sep 2026, 14:05 GMT+3 (Africa/Nairobi)" or "26 Oct 2026, 09:00 GMT"; '' for an invalid instant. */
|
|
30
|
+
text: string;
|
|
31
|
+
/** The IANA zone actually used. */
|
|
32
|
+
timezone: string;
|
|
33
|
+
/** True when `tz` was missing/invalid and the fallback zone was used. */
|
|
34
|
+
usedFallback: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Formats a UTC instant as local time in `tz`, including the zone.
|
|
38
|
+
* Invalid `tz` → fallback zone with `usedFallback: true`. Invalid instant → `text: ''`. Never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function formatInTimezone(instant: Date | string | number, tz: unknown, opts?: FormatInTimezoneOptions): FormattedInTimezone;
|
|
41
|
+
//# sourceMappingURL=timezone.utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timezone.utils.d.ts","sourceRoot":"","sources":["../../src/common/timezone.utils.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAqBH;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAa5D;AAED,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,OAAO,GAAG,EAAE,IAAI,MAAM,CAE7D;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAER,CAAC;AAExB,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,EAAE,EAAE,OAAO,EAAE,QAAQ,GAAE,MAAyB,GAAG,MAAM,CAExF;AAED,MAAM,WAAW,uBAAuB;IACtC,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,+GAA+G;IAC/G,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,YAAY,EAAE,OAAO,CAAC;CACvB;AAsBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,EAC/B,EAAE,EAAE,OAAO,EACX,IAAI,GAAE,uBAA4B,GACjC,mBAAmB,CA4BrB"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IANA timezone helpers.
|
|
3
|
+
*
|
|
4
|
+
* The database stores UTC; these helpers convert only at display time using
|
|
5
|
+
* the zone's own DST rules via Intl. Never pass a fixed offset ("+03:00",
|
|
6
|
+
* "GMT+3") as a timezone — those are rejected.
|
|
7
|
+
*/
|
|
8
|
+
const MAX_TIMEZONE_LENGTH = 100;
|
|
9
|
+
const HARD_DEFAULT_TIMEZONE = 'Africa/Nairobi';
|
|
10
|
+
/** Raw UTC offsets are not IANA zones, even where some Intl builds accept them. */
|
|
11
|
+
const RAW_OFFSET_PATTERN = /^(?:[+-]\d|(?:GMT|UTC)\s*[+-]\s*\d)/i;
|
|
12
|
+
/** A zone abbreviation Intl could not name, e.g. "GMT+3" or "GMT-04:30". */
|
|
13
|
+
const OFFSET_ABBREVIATION_PATTERN = /^(?:GMT|UTC)[+-]/;
|
|
14
|
+
let supportedZones;
|
|
15
|
+
function getSupportedZones() {
|
|
16
|
+
if (supportedZones === undefined) {
|
|
17
|
+
const supportedValuesOf = Intl.supportedValuesOf;
|
|
18
|
+
supportedZones = typeof supportedValuesOf === 'function' ? new Set(supportedValuesOf('timeZone')) : null;
|
|
19
|
+
}
|
|
20
|
+
return supportedZones;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Canonical casing of a valid IANA zone (e.g. "africa/nairobi" → "Africa/Nairobi"),
|
|
24
|
+
* or null when the value is not a valid IANA zone.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeTimezone(tz) {
|
|
27
|
+
if (typeof tz !== 'string')
|
|
28
|
+
return null;
|
|
29
|
+
const value = tz.trim();
|
|
30
|
+
if (!value || value.length > MAX_TIMEZONE_LENGTH || RAW_OFFSET_PATTERN.test(value))
|
|
31
|
+
return null;
|
|
32
|
+
if (getSupportedZones()?.has(value))
|
|
33
|
+
return value;
|
|
34
|
+
// Not in the canonical list: still accept aliases Intl understands (e.g. "UTC", "Asia/Calcutta").
|
|
35
|
+
try {
|
|
36
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** True when `tz` is an IANA timezone identifier Intl can format with. */
|
|
43
|
+
export function isValidIanaTimezone(tz) {
|
|
44
|
+
return normalizeTimezone(tz) !== null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Platform default for users whose timezone has never been observed.
|
|
48
|
+
* Overridable with DEFAULT_USER_TIMEZONE; an invalid override is ignored.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_TIMEZONE = normalizeTimezone(typeof process !== 'undefined' ? process.env?.DEFAULT_USER_TIMEZONE : undefined) ??
|
|
51
|
+
HARD_DEFAULT_TIMEZONE;
|
|
52
|
+
/** Returns `tz` (canonicalised) when valid, otherwise `fallback`. Never throws. */
|
|
53
|
+
export function resolveTimezone(tz, fallback = DEFAULT_TIMEZONE) {
|
|
54
|
+
return normalizeTimezone(tz) ?? normalizeTimezone(fallback) ?? HARD_DEFAULT_TIMEZONE;
|
|
55
|
+
}
|
|
56
|
+
function toDate(instant) {
|
|
57
|
+
const date = instant instanceof Date ? instant : new Date(instant);
|
|
58
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
59
|
+
}
|
|
60
|
+
function zoneAbbreviation(date, timeZone) {
|
|
61
|
+
// en-US names US zones (EDT, PST), en-GB names UK ones (BST, GMT); other zones come back as "GMT+3".
|
|
62
|
+
for (const locale of ['en-US', 'en-GB']) {
|
|
63
|
+
const name = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: 'short' })
|
|
64
|
+
.formatToParts(date)
|
|
65
|
+
.find((part) => part.type === 'timeZoneName')?.value;
|
|
66
|
+
if (name && !OFFSET_ABBREVIATION_PATTERN.test(name))
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
const offset = new Intl.DateTimeFormat('en-GB', { timeZone, timeZoneName: 'short' })
|
|
70
|
+
.formatToParts(date)
|
|
71
|
+
.find((part) => part.type === 'timeZoneName')?.value;
|
|
72
|
+
// An offset alone is ambiguous across zones and DST, so name the zone too.
|
|
73
|
+
return offset ? `${offset} (${timeZone})` : timeZone;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Formats a UTC instant as local time in `tz`, including the zone.
|
|
77
|
+
* Invalid `tz` → fallback zone with `usedFallback: true`. Invalid instant → `text: ''`. Never throws.
|
|
78
|
+
*/
|
|
79
|
+
export function formatInTimezone(instant, tz, opts = {}) {
|
|
80
|
+
const requested = normalizeTimezone(tz);
|
|
81
|
+
const timezone = requested ?? resolveTimezone(opts.fallback);
|
|
82
|
+
const usedFallback = requested === null;
|
|
83
|
+
const date = toDate(instant);
|
|
84
|
+
if (!date)
|
|
85
|
+
return { text: '', timezone, usedFallback };
|
|
86
|
+
try {
|
|
87
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
|
88
|
+
timeZone: timezone,
|
|
89
|
+
day: 'numeric',
|
|
90
|
+
month: 'short',
|
|
91
|
+
year: 'numeric',
|
|
92
|
+
hour: opts.hour12 ? 'numeric' : '2-digit',
|
|
93
|
+
minute: '2-digit',
|
|
94
|
+
hourCycle: opts.hour12 ? 'h12' : 'h23',
|
|
95
|
+
})
|
|
96
|
+
.formatToParts(date)
|
|
97
|
+
.map((part) => [part.type, part.value]));
|
|
98
|
+
const time = `${parts.hour}:${parts.minute}${opts.hour12 ? ` ${parts.dayPeriod}` : ''}`;
|
|
99
|
+
const text = `${parts.day} ${parts.month} ${parts.year}, ${time} ${zoneAbbreviation(date, timezone)}`;
|
|
100
|
+
return { text, timezone, usedFallback };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { text: '', timezone, usedFallback };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=timezone.utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timezone.utils.js","sourceRoot":"","sources":["../../src/common/timezone.utils.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permission.guard.d.ts","sourceRoot":"","sources":["../../src/express/permission.guard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"permission.guard.d.ts","sourceRoot":"","sources":["../../src/express/permission.guard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG/D;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,WAAW,EAAE,MAAM,EAAE,IAChD,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,KAAG,IAAI,CAgB/D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,WAAW,EAAE,MAAM,EAAE,IACpD,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,KAAG,IAAI,CAmB/D;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,IACpC,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,KAAG,IAAI,CAe/D"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasPermission } from '../common/permission.utils.js';
|
|
1
2
|
/**
|
|
2
3
|
* Require a specific permission. Superadmins bypass all checks.
|
|
3
4
|
*/
|
|
@@ -7,11 +8,7 @@ export function requirePermission(...permissions) {
|
|
|
7
8
|
res.status(401).json({ success: false, message: 'Authentication required' });
|
|
8
9
|
return;
|
|
9
10
|
}
|
|
10
|
-
if (req.user
|
|
11
|
-
return next();
|
|
12
|
-
// API keys may hold the '*' scope meaning all permissions the key's company has.
|
|
13
|
-
const hasPermission = req.user.permissions.includes('*') || permissions.some(p => req.user.permissions.includes(p));
|
|
14
|
-
if (!hasPermission) {
|
|
11
|
+
if (!hasPermission(req.user, ...permissions)) {
|
|
15
12
|
res.status(403).json({
|
|
16
13
|
success: false,
|
|
17
14
|
message: 'Insufficient permissions',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permission.guard.js","sourceRoot":"","sources":["../../src/express/permission.guard.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"permission.guard.js","sourceRoot":"","sources":["../../src/express/permission.guard.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAE9D;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAG,WAAqB;IACxD,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;QAC/D,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,yBAAyB,EAAE,CAAC,CAAC;YAC7E,OAAO;QACT,CAAC;QAED,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC;YAC7C,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;gBACnB,OAAO,EAAE,KAAK;gBACd,OAAO,EAAE,0BAA0B;gBACnC,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE;aAClC,CAAC,CAAC;YACH,OAAO;QACT,CAAC;QACD,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,qBAAqB,CAAC,GAAG,WAAqB;IAC5D,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;QAC/D,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,yBAAyB,EAAE,CAAC,CAAC;YAC7E,OAAO;QACT,CAAC;QAED,IAAI,GAAG,CAAC,IAAI,CAAC,YAAY;YAAE,OAAO,IAAI,EAAE,CAAC;QAEzC,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,IAAK,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QACzE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;gBACnB,OAAO,EAAE,KAAK;gBACd,OAAO,EAAE,0BAA0B;gBACnC,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE;aAClC,CAAC,CAAC;YACH,OAAO;QACT,CAAC;QACD,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,WAAW,CAAC,GAAG,KAAe;IAC5C,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;QAC/D,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,yBAAyB,EAAE,CAAC,CAAC;YAC7E,OAAO;QACT,CAAC;QAED,IAAI,GAAG,CAAC,IAAI,CAAC,YAAY;YAAE,OAAO,IAAI,EAAE,CAAC;QAEzC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,IAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC,CAAC;YACvE,OAAO;QACT,CAAC;QACD,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heavybit/pendoadmin-shared-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Shared library for PendoAdmin microservices",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsc",
|
|
30
30
|
"dev": "tsc --watch",
|
|
31
|
-
"clean": "rm -rf dist"
|
|
31
|
+
"clean": "rm -rf dist",
|
|
32
|
+
"test": "npm run build && node --test tests/"
|
|
32
33
|
},
|
|
33
34
|
"exports": {
|
|
34
35
|
".": "./dist/index.js",
|
package/src/common/index.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IAuthUser } from '../types/index.js';
|
|
2
|
+
|
|
3
|
+
/** The API-key scope that grants every permission the key's company has. */
|
|
4
|
+
export const ALL_PERMISSIONS_SCOPE = '*';
|
|
5
|
+
|
|
6
|
+
export type PermissionHolder = Pick<IAuthUser, 'isSuperAdmin' | 'permissions'>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when the user may act with ANY of the listed permissions: super admins always,
|
|
10
|
+
* API keys holding the '*' scope always, otherwise when one of the permissions is granted.
|
|
11
|
+
* This is the rule `requirePermission` enforces; use it for in-service checks so the two never drift.
|
|
12
|
+
*/
|
|
13
|
+
export function hasPermission(user: PermissionHolder | null | undefined, ...permissions: string[]): boolean {
|
|
14
|
+
if (!user) return false;
|
|
15
|
+
if (user.isSuperAdmin) return true;
|
|
16
|
+
const granted = user.permissions ?? [];
|
|
17
|
+
return granted.includes(ALL_PERMISSIONS_SCOPE) || permissions.some(p => granted.includes(p));
|
|
18
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IANA timezone helpers.
|
|
3
|
+
*
|
|
4
|
+
* The database stores UTC; these helpers convert only at display time using
|
|
5
|
+
* the zone's own DST rules via Intl. Never pass a fixed offset ("+03:00",
|
|
6
|
+
* "GMT+3") as a timezone — those are rejected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MAX_TIMEZONE_LENGTH = 100;
|
|
10
|
+
const HARD_DEFAULT_TIMEZONE = 'Africa/Nairobi';
|
|
11
|
+
|
|
12
|
+
/** Raw UTC offsets are not IANA zones, even where some Intl builds accept them. */
|
|
13
|
+
const RAW_OFFSET_PATTERN = /^(?:[+-]\d|(?:GMT|UTC)\s*[+-]\s*\d)/i;
|
|
14
|
+
|
|
15
|
+
/** A zone abbreviation Intl could not name, e.g. "GMT+3" or "GMT-04:30". */
|
|
16
|
+
const OFFSET_ABBREVIATION_PATTERN = /^(?:GMT|UTC)[+-]/;
|
|
17
|
+
|
|
18
|
+
let supportedZones: Set<string> | null | undefined;
|
|
19
|
+
|
|
20
|
+
function getSupportedZones(): Set<string> | null {
|
|
21
|
+
if (supportedZones === undefined) {
|
|
22
|
+
const supportedValuesOf = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf;
|
|
23
|
+
supportedZones = typeof supportedValuesOf === 'function' ? new Set(supportedValuesOf('timeZone')) : null;
|
|
24
|
+
}
|
|
25
|
+
return supportedZones;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Canonical casing of a valid IANA zone (e.g. "africa/nairobi" → "Africa/Nairobi"),
|
|
30
|
+
* or null when the value is not a valid IANA zone.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeTimezone(tz: unknown): string | null {
|
|
33
|
+
if (typeof tz !== 'string') return null;
|
|
34
|
+
const value = tz.trim();
|
|
35
|
+
if (!value || value.length > MAX_TIMEZONE_LENGTH || RAW_OFFSET_PATTERN.test(value)) return null;
|
|
36
|
+
|
|
37
|
+
if (getSupportedZones()?.has(value)) return value;
|
|
38
|
+
|
|
39
|
+
// Not in the canonical list: still accept aliases Intl understands (e.g. "UTC", "Asia/Calcutta").
|
|
40
|
+
try {
|
|
41
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when `tz` is an IANA timezone identifier Intl can format with. */
|
|
48
|
+
export function isValidIanaTimezone(tz: unknown): tz is string {
|
|
49
|
+
return normalizeTimezone(tz) !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Platform default for users whose timezone has never been observed.
|
|
54
|
+
* Overridable with DEFAULT_USER_TIMEZONE; an invalid override is ignored.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_TIMEZONE: string =
|
|
57
|
+
normalizeTimezone(typeof process !== 'undefined' ? process.env?.DEFAULT_USER_TIMEZONE : undefined) ??
|
|
58
|
+
HARD_DEFAULT_TIMEZONE;
|
|
59
|
+
|
|
60
|
+
/** Returns `tz` (canonicalised) when valid, otherwise `fallback`. Never throws. */
|
|
61
|
+
export function resolveTimezone(tz: unknown, fallback: string = DEFAULT_TIMEZONE): string {
|
|
62
|
+
return normalizeTimezone(tz) ?? normalizeTimezone(fallback) ?? HARD_DEFAULT_TIMEZONE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FormatInTimezoneOptions {
|
|
66
|
+
/** Zone used when `tz` is missing or invalid. Defaults to DEFAULT_TIMEZONE. */
|
|
67
|
+
fallback?: string;
|
|
68
|
+
/** 12-hour clock ("2:05 PM") instead of the default 24-hour ("14:05"). */
|
|
69
|
+
hour12?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FormattedInTimezone {
|
|
73
|
+
/** e.g. "26 Sep 2026, 14:05 GMT+3 (Africa/Nairobi)" or "26 Oct 2026, 09:00 GMT"; '' for an invalid instant. */
|
|
74
|
+
text: string;
|
|
75
|
+
/** The IANA zone actually used. */
|
|
76
|
+
timezone: string;
|
|
77
|
+
/** True when `tz` was missing/invalid and the fallback zone was used. */
|
|
78
|
+
usedFallback: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toDate(instant: Date | string | number): Date | null {
|
|
82
|
+
const date = instant instanceof Date ? instant : new Date(instant);
|
|
83
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function zoneAbbreviation(date: Date, timeZone: string): string {
|
|
87
|
+
// en-US names US zones (EDT, PST), en-GB names UK ones (BST, GMT); other zones come back as "GMT+3".
|
|
88
|
+
for (const locale of ['en-US', 'en-GB']) {
|
|
89
|
+
const name = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: 'short' })
|
|
90
|
+
.formatToParts(date)
|
|
91
|
+
.find((part) => part.type === 'timeZoneName')?.value;
|
|
92
|
+
if (name && !OFFSET_ABBREVIATION_PATTERN.test(name)) return name;
|
|
93
|
+
}
|
|
94
|
+
const offset = new Intl.DateTimeFormat('en-GB', { timeZone, timeZoneName: 'short' })
|
|
95
|
+
.formatToParts(date)
|
|
96
|
+
.find((part) => part.type === 'timeZoneName')?.value;
|
|
97
|
+
// An offset alone is ambiguous across zones and DST, so name the zone too.
|
|
98
|
+
return offset ? `${offset} (${timeZone})` : timeZone;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Formats a UTC instant as local time in `tz`, including the zone.
|
|
103
|
+
* Invalid `tz` → fallback zone with `usedFallback: true`. Invalid instant → `text: ''`. Never throws.
|
|
104
|
+
*/
|
|
105
|
+
export function formatInTimezone(
|
|
106
|
+
instant: Date | string | number,
|
|
107
|
+
tz: unknown,
|
|
108
|
+
opts: FormatInTimezoneOptions = {},
|
|
109
|
+
): FormattedInTimezone {
|
|
110
|
+
const requested = normalizeTimezone(tz);
|
|
111
|
+
const timezone = requested ?? resolveTimezone(opts.fallback);
|
|
112
|
+
const usedFallback = requested === null;
|
|
113
|
+
|
|
114
|
+
const date = toDate(instant);
|
|
115
|
+
if (!date) return { text: '', timezone, usedFallback };
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const parts = Object.fromEntries(
|
|
119
|
+
new Intl.DateTimeFormat('en-US', {
|
|
120
|
+
timeZone: timezone,
|
|
121
|
+
day: 'numeric',
|
|
122
|
+
month: 'short',
|
|
123
|
+
year: 'numeric',
|
|
124
|
+
hour: opts.hour12 ? 'numeric' : '2-digit',
|
|
125
|
+
minute: '2-digit',
|
|
126
|
+
hourCycle: opts.hour12 ? 'h12' : 'h23',
|
|
127
|
+
})
|
|
128
|
+
.formatToParts(date)
|
|
129
|
+
.map((part) => [part.type, part.value]),
|
|
130
|
+
);
|
|
131
|
+
const time = `${parts.hour}:${parts.minute}${opts.hour12 ? ` ${parts.dayPeriod}` : ''}`;
|
|
132
|
+
const text = `${parts.day} ${parts.month} ${parts.year}, ${time} ${zoneAbbreviation(date, timezone)}`;
|
|
133
|
+
return { text, timezone, usedFallback };
|
|
134
|
+
} catch {
|
|
135
|
+
return { text: '', timezone, usedFallback };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { hasPermission } from '../common/permission.utils.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Require a specific permission. Superadmins bypass all checks.
|
|
@@ -10,11 +11,7 @@ export function requirePermission(...permissions: string[]) {
|
|
|
10
11
|
return;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
if (req.user
|
|
14
|
-
|
|
15
|
-
// API keys may hold the '*' scope meaning all permissions the key's company has.
|
|
16
|
-
const hasPermission = req.user!.permissions.includes('*') || permissions.some(p => req.user!.permissions.includes(p));
|
|
17
|
-
if (!hasPermission) {
|
|
14
|
+
if (!hasPermission(req.user, ...permissions)) {
|
|
18
15
|
res.status(403).json({
|
|
19
16
|
success: false,
|
|
20
17
|
message: 'Insufficient permissions',
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { hasPermission, ALL_PERMISSIONS_SCOPE } from '../dist/common/index.js';
|
|
4
|
+
import { requirePermission } from '../dist/express/index.js';
|
|
5
|
+
|
|
6
|
+
const user = (permissions, isSuperAdmin = false) => ({ permissions, isSuperAdmin });
|
|
7
|
+
|
|
8
|
+
describe('hasPermission', () => {
|
|
9
|
+
test('grants when any listed permission is held', () => {
|
|
10
|
+
assert.equal(hasPermission(user(['users:read']), 'users.security:read', 'users:read'), true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('denies when none of the permissions is held', () => {
|
|
14
|
+
assert.equal(hasPermission(user(['users:read']), 'users.security:read'), false);
|
|
15
|
+
assert.equal(hasPermission(user([]), 'users:read'), false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('super admins always pass', () => {
|
|
19
|
+
assert.equal(hasPermission(user([], true), 'anything'), true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the '*' API-key scope grants every permission", () => {
|
|
23
|
+
assert.equal(ALL_PERMISSIONS_SCOPE, '*');
|
|
24
|
+
assert.equal(hasPermission(user(['*']), 'users.security:read'), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('no user, or missing permissions array, is denied', () => {
|
|
28
|
+
assert.equal(hasPermission(null, 'users:read'), false);
|
|
29
|
+
assert.equal(hasPermission(undefined, 'users:read'), false);
|
|
30
|
+
assert.equal(hasPermission({ isSuperAdmin: false }, 'users:read'), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('no permissions listed is denied for a normal user', () => {
|
|
34
|
+
assert.equal(hasPermission(user(['users:read'])), false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('requirePermission uses the same rule', () => {
|
|
39
|
+
function run(reqUser, ...perms) {
|
|
40
|
+
const res = { code: 200, status(c) { this.code = c; return this; }, json() { return this; } };
|
|
41
|
+
let nextCalled = false;
|
|
42
|
+
requirePermission(...perms)({ user: reqUser }, res, () => { nextCalled = true; });
|
|
43
|
+
return { nextCalled, code: res.code };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('401 without a user', () => {
|
|
47
|
+
assert.deepEqual(run(undefined, 'x'), { nextCalled: false, code: 401 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('403 without the permission', () => {
|
|
51
|
+
assert.deepEqual(run(user(['y']), 'x'), { nextCalled: false, code: 403 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('passes with the permission, the * scope, or as super admin', () => {
|
|
55
|
+
assert.equal(run(user(['x']), 'x').nextCalled, true);
|
|
56
|
+
assert.equal(run(user(['*']), 'x').nextCalled, true);
|
|
57
|
+
assert.equal(run(user([], true), 'x').nextCalled, true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import {
|
|
5
|
+
isValidIanaTimezone,
|
|
6
|
+
normalizeTimezone,
|
|
7
|
+
resolveTimezone,
|
|
8
|
+
formatInTimezone,
|
|
9
|
+
DEFAULT_TIMEZONE,
|
|
10
|
+
} from '../dist/common/index.js';
|
|
11
|
+
|
|
12
|
+
describe('isValidIanaTimezone', () => {
|
|
13
|
+
test('accepts canonical IANA zones and Intl-accepted aliases', () => {
|
|
14
|
+
for (const tz of ['Africa/Nairobi', 'Europe/London', 'America/New_York', 'UTC', 'Etc/UTC', 'Asia/Calcutta']) {
|
|
15
|
+
assert.equal(isValidIanaTimezone(tz), true, tz);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('rejects raw offsets', () => {
|
|
20
|
+
for (const tz of ['+03:00', '-05:00', '+3', 'GMT+3', 'GMT-04:30', 'UTC+3', 'utc - 5']) {
|
|
21
|
+
assert.equal(isValidIanaTimezone(tz), false, tz);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('rejects empty, non-string, unknown and over-long values', () => {
|
|
26
|
+
for (const tz of ['', ' ', null, undefined, 3, {}, 'Mars/Olympus_Mons', `Africa/${'x'.repeat(100)}`]) {
|
|
27
|
+
assert.equal(isValidIanaTimezone(tz), false, String(tz));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('normalizeTimezone', () => {
|
|
33
|
+
test('canonicalises casing and whitespace', () => {
|
|
34
|
+
assert.equal(normalizeTimezone(' africa/nairobi '), 'Africa/Nairobi');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('returns null for invalid values', () => {
|
|
38
|
+
assert.equal(normalizeTimezone('+03:00'), null);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('resolveTimezone', () => {
|
|
43
|
+
test('returns the zone when valid', () => {
|
|
44
|
+
assert.equal(resolveTimezone('Europe/London'), 'Europe/London');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('returns the fallback when invalid, never throws', () => {
|
|
48
|
+
assert.equal(resolveTimezone('nope', 'America/New_York'), 'America/New_York');
|
|
49
|
+
assert.equal(resolveTimezone(undefined), DEFAULT_TIMEZONE);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('an invalid fallback resolves to the platform default', () => {
|
|
53
|
+
assert.equal(resolveTimezone('nope', 'also-nope'), 'Africa/Nairobi');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('DEFAULT_TIMEZONE', () => {
|
|
58
|
+
const load = (env) =>
|
|
59
|
+
spawnSync(
|
|
60
|
+
process.execPath,
|
|
61
|
+
['--input-type=module', '-e', "import('./dist/common/index.js').then(m => process.stdout.write(m.DEFAULT_TIMEZONE))"],
|
|
62
|
+
{ cwd: new URL('..', import.meta.url), env: { ...process.env, ...env }, encoding: 'utf8' },
|
|
63
|
+
).stdout;
|
|
64
|
+
|
|
65
|
+
test('defaults to Africa/Nairobi', () => {
|
|
66
|
+
assert.equal(load({ DEFAULT_USER_TIMEZONE: '' }), 'Africa/Nairobi');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('honours a valid DEFAULT_USER_TIMEZONE override', () => {
|
|
70
|
+
assert.equal(load({ DEFAULT_USER_TIMEZONE: 'Europe/London' }), 'Europe/London');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('ignores an invalid DEFAULT_USER_TIMEZONE override', () => {
|
|
74
|
+
assert.equal(load({ DEFAULT_USER_TIMEZONE: '+03:00' }), 'Africa/Nairobi');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('formatInTimezone', () => {
|
|
79
|
+
test('UTC → Nairobi names the zone alongside its offset', () => {
|
|
80
|
+
const r = formatInTimezone('2026-09-26T11:05:00Z', 'Africa/Nairobi');
|
|
81
|
+
assert.deepEqual(r, {
|
|
82
|
+
text: '26 Sep 2026, 14:05 GMT+3 (Africa/Nairobi)',
|
|
83
|
+
timezone: 'Africa/Nairobi',
|
|
84
|
+
usedFallback: false,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('UTC → London before and after the BST start (2026-03-29)', () => {
|
|
89
|
+
assert.equal(formatInTimezone('2026-03-28T12:00:00Z', 'Europe/London').text, '28 Mar 2026, 12:00 GMT');
|
|
90
|
+
assert.equal(formatInTimezone('2026-03-29T12:00:00Z', 'Europe/London').text, '29 Mar 2026, 13:00 BST');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('UTC → London before and after the BST end (2026-10-25)', () => {
|
|
94
|
+
assert.equal(formatInTimezone('2026-10-24T12:00:00Z', 'Europe/London').text, '24 Oct 2026, 13:00 BST');
|
|
95
|
+
assert.equal(formatInTimezone('2026-10-25T12:00:00Z', 'Europe/London').text, '25 Oct 2026, 12:00 GMT');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('America/New_York follows DST', () => {
|
|
99
|
+
assert.equal(formatInTimezone('2026-07-01T16:00:00Z', 'America/New_York').text, '1 Jul 2026, 12:00 EDT');
|
|
100
|
+
assert.equal(formatInTimezone('2026-12-01T16:00:00Z', 'America/New_York').text, '1 Dec 2026, 11:00 EST');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('crosses midnight into the next and previous day', () => {
|
|
104
|
+
assert.equal(formatInTimezone('2026-09-26T22:30:00Z', 'Africa/Nairobi').text, '27 Sep 2026, 01:30 GMT+3 (Africa/Nairobi)');
|
|
105
|
+
assert.equal(formatInTimezone('2026-09-27T02:30:00Z', 'America/New_York').text, '26 Sep 2026, 22:30 EDT');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('accepts Date, ISO string and epoch millis', () => {
|
|
109
|
+
const iso = '2026-09-26T11:05:00Z';
|
|
110
|
+
const expected = formatInTimezone(iso, 'UTC').text;
|
|
111
|
+
assert.equal(expected, '26 Sep 2026, 11:05 UTC');
|
|
112
|
+
assert.equal(formatInTimezone(new Date(iso), 'UTC').text, expected);
|
|
113
|
+
assert.equal(formatInTimezone(Date.parse(iso), 'UTC').text, expected);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('supports a 12-hour clock', () => {
|
|
117
|
+
assert.equal(formatInTimezone('2026-09-26T11:05:00Z', 'UTC', { hour12: true }).text, '26 Sep 2026, 11:05 AM UTC');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('invalid tz uses the fallback and flags it', () => {
|
|
121
|
+
const r = formatInTimezone('2026-09-26T11:05:00Z', '+03:00', { fallback: 'Europe/London' });
|
|
122
|
+
assert.equal(r.timezone, 'Europe/London');
|
|
123
|
+
assert.equal(r.usedFallback, true);
|
|
124
|
+
assert.equal(r.text, '26 Sep 2026, 12:05 BST');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('missing tz falls back to DEFAULT_TIMEZONE', () => {
|
|
128
|
+
const r = formatInTimezone('2026-09-26T11:05:00Z', null);
|
|
129
|
+
assert.equal(r.timezone, DEFAULT_TIMEZONE);
|
|
130
|
+
assert.equal(r.usedFallback, true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('invalid instant returns empty text and never throws', () => {
|
|
134
|
+
for (const instant of ['not a date', NaN, new Date('x')]) {
|
|
135
|
+
const r = formatInTimezone(instant, 'Africa/Nairobi');
|
|
136
|
+
assert.equal(r.text, '');
|
|
137
|
+
assert.equal(r.timezone, 'Africa/Nairobi');
|
|
138
|
+
assert.equal(r.usedFallback, false);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|