@catlabtech/mycal-core 0.1.2 → 0.1.3
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/business-days.d.ts +7 -0
- package/dist/business-days.js +106 -0
- package/dist/filter.d.ts +18 -0
- package/dist/filter.js +75 -0
- package/dist/hijri.d.ts +36 -0
- package/dist/hijri.js +59 -0
- package/dist/ical.d.ts +2 -0
- package/dist/ical.js +90 -0
- package/dist/index.d.ts +14 -892
- package/dist/index.js +22 -790
- package/dist/long-weekend.d.ts +18 -0
- package/dist/long-weekend.js +127 -0
- package/dist/replacement.d.ts +2 -0
- package/dist/replacement.js +69 -0
- package/dist/schemas.d.ts +652 -0
- package/dist/schemas.js +136 -0
- package/dist/school.d.ts +10 -0
- package/dist/school.js +44 -0
- package/dist/state-resolver.d.ts +4 -0
- package/dist/state-resolver.js +18 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +2 -0
- package/dist/validation.d.ts +21 -0
- package/dist/validation.js +94 -0
- package/dist/weekend.d.ts +8 -0
- package/dist/weekend.js +57 -0
- package/package.json +1 -1
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO 8601 date (YYYY-MM-DD)");
|
|
3
|
+
const localizedString = z.object({
|
|
4
|
+
ms: z.string().min(1),
|
|
5
|
+
en: z.string().min(1),
|
|
6
|
+
zh: z.string().optional(),
|
|
7
|
+
});
|
|
8
|
+
// ─── Holiday Schema ───
|
|
9
|
+
export const holidayTypeSchema = z.enum([
|
|
10
|
+
"federal",
|
|
11
|
+
"state",
|
|
12
|
+
"islamic",
|
|
13
|
+
"islamic_state",
|
|
14
|
+
"replacement",
|
|
15
|
+
"adhoc",
|
|
16
|
+
]);
|
|
17
|
+
export const holidayStatusSchema = z.enum([
|
|
18
|
+
"confirmed",
|
|
19
|
+
"tentative",
|
|
20
|
+
"announced",
|
|
21
|
+
"cancelled",
|
|
22
|
+
]);
|
|
23
|
+
export const gazetteLevelSchema = z.enum(["P", "N"]);
|
|
24
|
+
export const holidayCategorySchema = z.enum([
|
|
25
|
+
"public",
|
|
26
|
+
"bank",
|
|
27
|
+
"optional",
|
|
28
|
+
"half_day",
|
|
29
|
+
"observance",
|
|
30
|
+
]);
|
|
31
|
+
export const holidaySourceSchema = z.enum([
|
|
32
|
+
"jpm",
|
|
33
|
+
"jakim",
|
|
34
|
+
"state-gov",
|
|
35
|
+
"community",
|
|
36
|
+
"admin",
|
|
37
|
+
]);
|
|
38
|
+
export const holidaySchema = z.object({
|
|
39
|
+
id: z.string().min(1),
|
|
40
|
+
date: isoDate,
|
|
41
|
+
endDate: isoDate.optional(),
|
|
42
|
+
name: localizedString,
|
|
43
|
+
type: holidayTypeSchema,
|
|
44
|
+
status: holidayStatusSchema,
|
|
45
|
+
states: z.array(z.string().min(1)).min(1),
|
|
46
|
+
isPublicHoliday: z.boolean(),
|
|
47
|
+
gazetteLevel: gazetteLevelSchema,
|
|
48
|
+
isReplacementFor: z.string().optional(),
|
|
49
|
+
hijriDate: z.string().optional(),
|
|
50
|
+
gazetteRef: z.string().optional(),
|
|
51
|
+
source: holidaySourceSchema,
|
|
52
|
+
category: holidayCategorySchema.optional(),
|
|
53
|
+
isEstimated: z.boolean().optional(),
|
|
54
|
+
confirmedAt: z.string().optional(),
|
|
55
|
+
createdAt: z.string(),
|
|
56
|
+
updatedAt: z.string(),
|
|
57
|
+
});
|
|
58
|
+
export const holidayFileSchema = z.array(holidaySchema);
|
|
59
|
+
// ─── State Schema ───
|
|
60
|
+
export const stateGroupSchema = z.enum(["A", "B"]);
|
|
61
|
+
export const weekendConfigSchema = z.object({
|
|
62
|
+
from: isoDate,
|
|
63
|
+
to: isoDate.nullable(),
|
|
64
|
+
weekendDays: z.array(z.number().int().min(0).max(6)),
|
|
65
|
+
group: stateGroupSchema,
|
|
66
|
+
});
|
|
67
|
+
export const stateSchema = z.object({
|
|
68
|
+
code: z.string().min(1),
|
|
69
|
+
aliases: z.array(z.string()),
|
|
70
|
+
name: localizedString,
|
|
71
|
+
type: z.enum(["state", "federal_territory"]),
|
|
72
|
+
weekendHistory: z.array(weekendConfigSchema).min(1),
|
|
73
|
+
group: stateGroupSchema,
|
|
74
|
+
isoCode: z.string().optional(),
|
|
75
|
+
});
|
|
76
|
+
export const statesFileSchema = z.array(stateSchema);
|
|
77
|
+
// ─── School Calendar Schemas ───
|
|
78
|
+
export const schoolTermSegmentSchema = z.object({
|
|
79
|
+
startDate: isoDate,
|
|
80
|
+
endDate: isoDate,
|
|
81
|
+
schoolDays: z.number().int().min(0),
|
|
82
|
+
});
|
|
83
|
+
export const schoolTermSchema = z.object({
|
|
84
|
+
id: z.string().min(1),
|
|
85
|
+
year: z.number().int().min(2020).max(2100),
|
|
86
|
+
term: z.union([z.literal(1), z.literal(2)]),
|
|
87
|
+
group: stateGroupSchema,
|
|
88
|
+
segments: z.array(schoolTermSegmentSchema).min(1),
|
|
89
|
+
totalSchoolDays: z.number().int().min(0),
|
|
90
|
+
startDate: isoDate,
|
|
91
|
+
endDate: isoDate,
|
|
92
|
+
});
|
|
93
|
+
export const schoolHolidayTypeSchema = z.enum([
|
|
94
|
+
"cuti_penggal_1",
|
|
95
|
+
"cuti_pertengahan",
|
|
96
|
+
"cuti_penggal_2",
|
|
97
|
+
"cuti_akhir",
|
|
98
|
+
"cuti_perayaan_kpm",
|
|
99
|
+
]);
|
|
100
|
+
export const schoolHolidaySchema = z.object({
|
|
101
|
+
id: z.string().min(1),
|
|
102
|
+
year: z.number().int().min(2020).max(2100),
|
|
103
|
+
group: stateGroupSchema,
|
|
104
|
+
type: schoolHolidayTypeSchema,
|
|
105
|
+
name: localizedString,
|
|
106
|
+
startDate: isoDate,
|
|
107
|
+
endDate: isoDate,
|
|
108
|
+
days: z.number().int().min(1),
|
|
109
|
+
states: z.array(z.string()).optional(),
|
|
110
|
+
excludeStates: z.array(z.string()).optional(),
|
|
111
|
+
remarks: z.string().optional(),
|
|
112
|
+
});
|
|
113
|
+
export const schoolTermsFileSchema = z.array(schoolTermSchema);
|
|
114
|
+
export const schoolHolidaysFileSchema = z.array(schoolHolidaySchema);
|
|
115
|
+
// ─── Exam Schema ───
|
|
116
|
+
export const examTypeSchema = z.enum([
|
|
117
|
+
"spm",
|
|
118
|
+
"stpm",
|
|
119
|
+
"muet",
|
|
120
|
+
"pt3",
|
|
121
|
+
"stam",
|
|
122
|
+
"other",
|
|
123
|
+
]);
|
|
124
|
+
export const examSchema = z.object({
|
|
125
|
+
id: z.string().min(1),
|
|
126
|
+
year: z.number().int().min(2020).max(2100),
|
|
127
|
+
name: z.string().min(1),
|
|
128
|
+
fullName: localizedString,
|
|
129
|
+
type: examTypeSchema,
|
|
130
|
+
startDate: isoDate,
|
|
131
|
+
endDate: isoDate.optional(),
|
|
132
|
+
status: z.enum(["confirmed", "tentative"]),
|
|
133
|
+
resultsDate: isoDate.optional(),
|
|
134
|
+
source: z.enum(["kpm", "mpm"]),
|
|
135
|
+
});
|
|
136
|
+
export const examsFileSchema = z.array(examSchema);
|
package/dist/school.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SchoolTerm, SchoolHoliday, StateGroup } from "./types.js";
|
|
2
|
+
export declare function findSchoolTermByDate(date: string, terms: readonly SchoolTerm[], group: StateGroup): SchoolTerm | null;
|
|
3
|
+
export declare function findSchoolHolidayByDate(date: string, holidays: readonly SchoolHoliday[], group: StateGroup, stateCode?: string): SchoolHoliday | null;
|
|
4
|
+
/**
|
|
5
|
+
* List the school holidays that apply to a group (and optionally a specific
|
|
6
|
+
* state, honouring `states`/`excludeStates` overrides). Shared by the REST
|
|
7
|
+
* `/school/holidays` and iCal feed routes so the filter logic lives in one place.
|
|
8
|
+
*/
|
|
9
|
+
export declare function filterSchoolHolidays(holidays: readonly SchoolHoliday[], group: StateGroup, stateCode?: string): readonly SchoolHoliday[];
|
|
10
|
+
export declare function isSchoolDay(date: string, terms: readonly SchoolTerm[], schoolHolidays: readonly SchoolHoliday[], group: StateGroup, isPublicHoliday: boolean, isWeekendDay: boolean, stateCode?: string): boolean;
|
package/dist/school.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function findSchoolTermByDate(date, terms, group) {
|
|
2
|
+
return (terms.find((t) => t.group === group && date >= t.startDate && date <= t.endDate) ?? null);
|
|
3
|
+
}
|
|
4
|
+
export function findSchoolHolidayByDate(date, holidays, group, stateCode) {
|
|
5
|
+
return (holidays.find((h) => {
|
|
6
|
+
if (h.group !== group)
|
|
7
|
+
return false;
|
|
8
|
+
if (date < h.startDate || date > h.endDate)
|
|
9
|
+
return false;
|
|
10
|
+
// Check state-level exceptions
|
|
11
|
+
if (stateCode && h.excludeStates?.includes(stateCode))
|
|
12
|
+
return false;
|
|
13
|
+
if (h.states && !h.states.includes(stateCode ?? ""))
|
|
14
|
+
return false;
|
|
15
|
+
return true;
|
|
16
|
+
}) ?? null);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* List the school holidays that apply to a group (and optionally a specific
|
|
20
|
+
* state, honouring `states`/`excludeStates` overrides). Shared by the REST
|
|
21
|
+
* `/school/holidays` and iCal feed routes so the filter logic lives in one place.
|
|
22
|
+
*/
|
|
23
|
+
export function filterSchoolHolidays(holidays, group, stateCode) {
|
|
24
|
+
return holidays.filter((h) => {
|
|
25
|
+
if (h.group !== group)
|
|
26
|
+
return false;
|
|
27
|
+
if (stateCode && h.excludeStates?.includes(stateCode))
|
|
28
|
+
return false;
|
|
29
|
+
if (h.states && stateCode && !h.states.includes(stateCode))
|
|
30
|
+
return false;
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function isSchoolDay(date, terms, schoolHolidays, group, isPublicHoliday, isWeekendDay, stateCode) {
|
|
35
|
+
if (isWeekendDay)
|
|
36
|
+
return false;
|
|
37
|
+
if (isPublicHoliday)
|
|
38
|
+
return false;
|
|
39
|
+
const schoolHoliday = findSchoolHolidayByDate(date, schoolHolidays, group, stateCode);
|
|
40
|
+
if (schoolHoliday)
|
|
41
|
+
return false;
|
|
42
|
+
const term = findSchoolTermByDate(date, terms, group);
|
|
43
|
+
return term !== null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { State } from "./types.js";
|
|
2
|
+
export declare function resolveStateCode(query: string, states: readonly State[]): State | null;
|
|
3
|
+
export declare function getStateByCode(code: string, states: readonly State[]): State | null;
|
|
4
|
+
export declare function getStatesByGroup(group: "A" | "B", states: readonly State[]): readonly State[];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function resolveStateCode(query, states) {
|
|
2
|
+
const normalized = query.toLowerCase().trim();
|
|
3
|
+
for (const state of states) {
|
|
4
|
+
if (state.code === normalized)
|
|
5
|
+
return state;
|
|
6
|
+
for (const alias of state.aliases) {
|
|
7
|
+
if (alias.toLowerCase() === normalized)
|
|
8
|
+
return state;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
export function getStateByCode(code, states) {
|
|
14
|
+
return states.find((s) => s.code === code) ?? null;
|
|
15
|
+
}
|
|
16
|
+
export function getStatesByGroup(group, states) {
|
|
17
|
+
return states.filter((s) => s.group === group);
|
|
18
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export interface LocalizedString {
|
|
2
|
+
readonly ms: string;
|
|
3
|
+
readonly en: string;
|
|
4
|
+
readonly zh?: string;
|
|
5
|
+
}
|
|
6
|
+
export type HolidayType = "federal" | "state" | "islamic" | "islamic_state" | "replacement" | "adhoc";
|
|
7
|
+
export type HolidayCategory = "public" | "bank" | "optional" | "half_day" | "observance";
|
|
8
|
+
export type HolidayStatus = "confirmed" | "tentative" | "announced" | "cancelled";
|
|
9
|
+
export type GazetteLevel = "P" | "N";
|
|
10
|
+
export type HolidaySource = "jpm" | "jakim" | "state-gov" | "community" | "admin";
|
|
11
|
+
export interface Holiday {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly date: string;
|
|
14
|
+
readonly endDate?: string;
|
|
15
|
+
readonly name: LocalizedString;
|
|
16
|
+
readonly type: HolidayType;
|
|
17
|
+
readonly status: HolidayStatus;
|
|
18
|
+
readonly states: readonly string[];
|
|
19
|
+
readonly isPublicHoliday: boolean;
|
|
20
|
+
readonly gazetteLevel: GazetteLevel;
|
|
21
|
+
readonly isReplacementFor?: string;
|
|
22
|
+
readonly hijriDate?: string;
|
|
23
|
+
readonly gazetteRef?: string;
|
|
24
|
+
readonly source: HolidaySource;
|
|
25
|
+
readonly category?: HolidayCategory;
|
|
26
|
+
readonly isEstimated?: boolean;
|
|
27
|
+
readonly confirmedAt?: string;
|
|
28
|
+
readonly createdAt: string;
|
|
29
|
+
readonly updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
export type StateGroup = "A" | "B";
|
|
32
|
+
export interface WeekendConfig {
|
|
33
|
+
readonly from: string;
|
|
34
|
+
readonly to: string | null;
|
|
35
|
+
readonly weekendDays: readonly number[];
|
|
36
|
+
readonly group: StateGroup;
|
|
37
|
+
}
|
|
38
|
+
export type StateType = "state" | "federal_territory";
|
|
39
|
+
export interface State {
|
|
40
|
+
readonly code: string;
|
|
41
|
+
readonly aliases: readonly string[];
|
|
42
|
+
readonly name: LocalizedString;
|
|
43
|
+
readonly type: StateType;
|
|
44
|
+
readonly weekendHistory: readonly WeekendConfig[];
|
|
45
|
+
readonly group: StateGroup;
|
|
46
|
+
readonly isoCode?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface SchoolTermSegment {
|
|
49
|
+
readonly startDate: string;
|
|
50
|
+
readonly endDate: string;
|
|
51
|
+
readonly schoolDays: number;
|
|
52
|
+
}
|
|
53
|
+
export interface SchoolTerm {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly year: number;
|
|
56
|
+
readonly term: 1 | 2;
|
|
57
|
+
readonly group: StateGroup;
|
|
58
|
+
readonly segments: readonly SchoolTermSegment[];
|
|
59
|
+
readonly totalSchoolDays: number;
|
|
60
|
+
readonly startDate: string;
|
|
61
|
+
readonly endDate: string;
|
|
62
|
+
}
|
|
63
|
+
export type SchoolHolidayType = "cuti_penggal_1" | "cuti_pertengahan" | "cuti_penggal_2" | "cuti_akhir" | "cuti_perayaan_kpm";
|
|
64
|
+
export interface SchoolHoliday {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly year: number;
|
|
67
|
+
readonly group: StateGroup;
|
|
68
|
+
readonly type: SchoolHolidayType;
|
|
69
|
+
readonly name: LocalizedString;
|
|
70
|
+
readonly startDate: string;
|
|
71
|
+
readonly endDate: string;
|
|
72
|
+
readonly days: number;
|
|
73
|
+
readonly states?: readonly string[];
|
|
74
|
+
readonly excludeStates?: readonly string[];
|
|
75
|
+
readonly remarks?: string;
|
|
76
|
+
}
|
|
77
|
+
export type ExamType = "spm" | "stpm" | "muet" | "pt3" | "stam" | "other";
|
|
78
|
+
export type ExamSource = "kpm" | "mpm";
|
|
79
|
+
export interface Exam {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly year: number;
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly fullName: LocalizedString;
|
|
84
|
+
readonly type: ExamType;
|
|
85
|
+
readonly startDate: string;
|
|
86
|
+
readonly endDate?: string;
|
|
87
|
+
readonly status: "confirmed" | "tentative";
|
|
88
|
+
readonly resultsDate?: string;
|
|
89
|
+
readonly source: ExamSource;
|
|
90
|
+
}
|
|
91
|
+
export interface CheckDateResult {
|
|
92
|
+
readonly date: string;
|
|
93
|
+
readonly dayOfWeek: string;
|
|
94
|
+
readonly isHoliday: boolean;
|
|
95
|
+
readonly isWeekend: boolean;
|
|
96
|
+
readonly isWorkingDay: boolean;
|
|
97
|
+
readonly isSchoolDay: boolean;
|
|
98
|
+
readonly holidays: readonly Holiday[];
|
|
99
|
+
readonly school: {
|
|
100
|
+
readonly group: StateGroup;
|
|
101
|
+
readonly term: SchoolTerm | null;
|
|
102
|
+
readonly holiday: SchoolHoliday | null;
|
|
103
|
+
} | null;
|
|
104
|
+
readonly state: {
|
|
105
|
+
readonly code: string;
|
|
106
|
+
readonly weekendDays: readonly string[];
|
|
107
|
+
readonly group: StateGroup;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export interface BusinessDaysResult {
|
|
111
|
+
readonly totalDays: number;
|
|
112
|
+
readonly businessDays: number;
|
|
113
|
+
readonly holidays: number;
|
|
114
|
+
readonly weekendDays: number;
|
|
115
|
+
readonly holidayList: readonly Holiday[];
|
|
116
|
+
}
|
|
117
|
+
export interface LongWeekend {
|
|
118
|
+
readonly startDate: string;
|
|
119
|
+
readonly endDate: string;
|
|
120
|
+
readonly totalDays: number;
|
|
121
|
+
readonly holidays: readonly Holiday[];
|
|
122
|
+
readonly weekendDays: number;
|
|
123
|
+
readonly bridgeDaysNeeded: number;
|
|
124
|
+
}
|
|
125
|
+
export interface LeaveSuggestion {
|
|
126
|
+
readonly startDate: string;
|
|
127
|
+
readonly endDate: string;
|
|
128
|
+
readonly totalDays: number;
|
|
129
|
+
readonly leaveDates: readonly string[];
|
|
130
|
+
readonly leaveCost: number;
|
|
131
|
+
readonly efficiency: number;
|
|
132
|
+
readonly holidays: readonly Holiday[];
|
|
133
|
+
}
|
|
134
|
+
export interface ChangelogEntry {
|
|
135
|
+
readonly timestamp: string;
|
|
136
|
+
readonly event: string;
|
|
137
|
+
readonly holidayId?: string;
|
|
138
|
+
readonly examId?: string;
|
|
139
|
+
readonly description: string;
|
|
140
|
+
readonly changes?: Record<string, {
|
|
141
|
+
from: unknown;
|
|
142
|
+
to: unknown;
|
|
143
|
+
}>;
|
|
144
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input-validation and request-safety helpers shared by the API layer. Kept here
|
|
3
|
+
* (pure, dependency-free) so they are unit-testable and reusable by any consumer.
|
|
4
|
+
*/
|
|
5
|
+
/** True only for a real calendar date in strict `YYYY-MM-DD` form (rejects 2026-02-30). */
|
|
6
|
+
export declare function isValidISODate(value: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Constant-time string comparison for secrets (API keys). Returns early only on
|
|
9
|
+
* a length mismatch; otherwise compares every byte so timing does not leak how
|
|
10
|
+
* many leading characters matched.
|
|
11
|
+
*/
|
|
12
|
+
export declare function timingSafeEqualString(a: string, b: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* SSRF guard for user-supplied callback/webhook URLs. Requires HTTPS and rejects
|
|
15
|
+
* obviously-internal targets (localhost, *.local/.internal, and private/loopback/
|
|
16
|
+
* link-local/reserved IP literals incl. the 169.254.169.254 cloud-metadata IP).
|
|
17
|
+
*
|
|
18
|
+
* Best-effort at registration time: DNS is NOT resolved here, so delivery code
|
|
19
|
+
* MUST re-validate the resolved IP to defend against DNS rebinding.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isSafePublicHttpsUrl(raw: string): boolean;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input-validation and request-safety helpers shared by the API layer. Kept here
|
|
3
|
+
* (pure, dependency-free) so they are unit-testable and reusable by any consumer.
|
|
4
|
+
*/
|
|
5
|
+
/** True only for a real calendar date in strict `YYYY-MM-DD` form (rejects 2026-02-30). */
|
|
6
|
+
export function isValidISODate(value) {
|
|
7
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
8
|
+
return false;
|
|
9
|
+
const d = new Date(value + "T12:00:00Z");
|
|
10
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Constant-time string comparison for secrets (API keys). Returns early only on
|
|
14
|
+
* a length mismatch; otherwise compares every byte so timing does not leak how
|
|
15
|
+
* many leading characters matched.
|
|
16
|
+
*/
|
|
17
|
+
export function timingSafeEqualString(a, b) {
|
|
18
|
+
const enc = new TextEncoder();
|
|
19
|
+
const ab = enc.encode(a);
|
|
20
|
+
const bb = enc.encode(b);
|
|
21
|
+
if (ab.length !== bb.length)
|
|
22
|
+
return false;
|
|
23
|
+
let diff = 0;
|
|
24
|
+
for (let i = 0; i < ab.length; i++)
|
|
25
|
+
diff |= ab[i] ^ bb[i];
|
|
26
|
+
return diff === 0;
|
|
27
|
+
}
|
|
28
|
+
function isPrivateOrReservedIp(host) {
|
|
29
|
+
// IPv6 literal (URL.hostname has already stripped the [brackets]).
|
|
30
|
+
if (host.includes(":")) {
|
|
31
|
+
if (host === "::1" || host === "::")
|
|
32
|
+
return true;
|
|
33
|
+
if (host.startsWith("fe80"))
|
|
34
|
+
return true; // link-local
|
|
35
|
+
if (host.startsWith("fc") || host.startsWith("fd"))
|
|
36
|
+
return true; // unique-local
|
|
37
|
+
if (host.startsWith("::ffff:"))
|
|
38
|
+
return isPrivateOrReservedIp(host.slice(7));
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
42
|
+
if (!m)
|
|
43
|
+
return false; // not an IP literal → a hostname (resolve-time check needed)
|
|
44
|
+
const octets = m.slice(1).map(Number);
|
|
45
|
+
if (octets.some((n) => n > 255))
|
|
46
|
+
return true; // malformed → block
|
|
47
|
+
const [a, b] = octets;
|
|
48
|
+
if (a === 0)
|
|
49
|
+
return true; // "this" network
|
|
50
|
+
if (a === 10)
|
|
51
|
+
return true; // private
|
|
52
|
+
if (a === 127)
|
|
53
|
+
return true; // loopback
|
|
54
|
+
if (a === 169 && b === 254)
|
|
55
|
+
return true; // link-local + cloud metadata 169.254.169.254
|
|
56
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
57
|
+
return true; // private
|
|
58
|
+
if (a === 192 && b === 168)
|
|
59
|
+
return true; // private
|
|
60
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
61
|
+
return true; // CGNAT (RFC 6598)
|
|
62
|
+
if (a >= 224)
|
|
63
|
+
return true; // multicast / reserved
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* SSRF guard for user-supplied callback/webhook URLs. Requires HTTPS and rejects
|
|
68
|
+
* obviously-internal targets (localhost, *.local/.internal, and private/loopback/
|
|
69
|
+
* link-local/reserved IP literals incl. the 169.254.169.254 cloud-metadata IP).
|
|
70
|
+
*
|
|
71
|
+
* Best-effort at registration time: DNS is NOT resolved here, so delivery code
|
|
72
|
+
* MUST re-validate the resolved IP to defend against DNS rebinding.
|
|
73
|
+
*/
|
|
74
|
+
export function isSafePublicHttpsUrl(raw) {
|
|
75
|
+
let url;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(raw);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (url.protocol !== "https:")
|
|
83
|
+
return false;
|
|
84
|
+
// URL.hostname keeps the [brackets] around IPv6 literals — strip them so the
|
|
85
|
+
// address checks below see a bare "::1" / "fe80::…".
|
|
86
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
87
|
+
if (host === "localhost" ||
|
|
88
|
+
host.endsWith(".localhost") ||
|
|
89
|
+
host.endsWith(".local") ||
|
|
90
|
+
host.endsWith(".internal")) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return !isPrivateOrReservedIp(host);
|
|
94
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { State, WeekendConfig } from "./types.js";
|
|
2
|
+
export declare function getWeekendConfig(state: State, date: string): WeekendConfig;
|
|
3
|
+
export declare function isWeekend(date: string, state: State): boolean;
|
|
4
|
+
export declare function getDayOfWeekName(date: string): string;
|
|
5
|
+
export declare function getWeekendDayNames(state: State, date: string): readonly string[];
|
|
6
|
+
export declare function addDays(date: string, days: number): string;
|
|
7
|
+
export declare function nextWorkingDay(date: string, state: State, holidayDates: ReadonlySet<string>): string;
|
|
8
|
+
export declare function diffDays(start: string, end: string): number;
|
package/dist/weekend.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const DAY_NAMES = [
|
|
2
|
+
"Sunday",
|
|
3
|
+
"Monday",
|
|
4
|
+
"Tuesday",
|
|
5
|
+
"Wednesday",
|
|
6
|
+
"Thursday",
|
|
7
|
+
"Friday",
|
|
8
|
+
"Saturday",
|
|
9
|
+
];
|
|
10
|
+
// `weekendHistory` MUST be ordered newest-first: this returns the first config
|
|
11
|
+
// whose [from, to] range covers `date`, and the fallback below returns entry [0]
|
|
12
|
+
// (the most recent) when no range matches. The schema enforces a non-empty array.
|
|
13
|
+
export function getWeekendConfig(state, date) {
|
|
14
|
+
for (const config of state.weekendHistory) {
|
|
15
|
+
const from = config.from;
|
|
16
|
+
const to = config.to;
|
|
17
|
+
if (date >= from && (to === null || date <= to)) {
|
|
18
|
+
return config;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Fallback: use first (most recent) config
|
|
22
|
+
return state.weekendHistory[0];
|
|
23
|
+
}
|
|
24
|
+
export function isWeekend(date, state) {
|
|
25
|
+
const config = getWeekendConfig(state, date);
|
|
26
|
+
const dayOfWeek = new Date(date + "T12:00:00Z").getUTCDay();
|
|
27
|
+
return config.weekendDays.includes(dayOfWeek);
|
|
28
|
+
}
|
|
29
|
+
export function getDayOfWeekName(date) {
|
|
30
|
+
const dayIndex = new Date(date + "T12:00:00Z").getUTCDay();
|
|
31
|
+
return DAY_NAMES[dayIndex];
|
|
32
|
+
}
|
|
33
|
+
export function getWeekendDayNames(state, date) {
|
|
34
|
+
const config = getWeekendConfig(state, date);
|
|
35
|
+
return config.weekendDays.map((d) => DAY_NAMES[d]);
|
|
36
|
+
}
|
|
37
|
+
export function addDays(date, days) {
|
|
38
|
+
const d = new Date(date + "T12:00:00Z");
|
|
39
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
40
|
+
return d.toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
export function nextWorkingDay(date, state, holidayDates) {
|
|
43
|
+
let candidate = addDays(date, 1);
|
|
44
|
+
while (isWeekend(candidate, state) || holidayDates.has(candidate)) {
|
|
45
|
+
candidate = addDays(candidate, 1);
|
|
46
|
+
}
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
export function diffDays(start, end) {
|
|
50
|
+
// Anchor at noon UTC (like every other date helper here) so whole-day
|
|
51
|
+
// arithmetic is immune to the runtime timezone and DST. Parsing as
|
|
52
|
+
// "YYYY-MM-DDT00:00:00" without a zone would use local time and could be
|
|
53
|
+
// off by a day for contributors running outside UTC.
|
|
54
|
+
const s = new Date(start + "T12:00:00Z").getTime();
|
|
55
|
+
const e = new Date(end + "T12:00:00Z").getTime();
|
|
56
|
+
return Math.round((e - s) / 86_400_000);
|
|
57
|
+
}
|