@crewhaus/tool-notify 0.7.0 → 0.8.1
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +58 -5
- package/dist/index.js +371 -96
- package/dist/lib/instant.d.ts +57 -0
- package/dist/lib/instant.js +188 -0
- package/dist/lib/preflight.js +7 -9
- package/dist/lib/quiet.d.ts +3 -1
- package/dist/lib/quiet.js +48 -15
- package/dist/lib/ratelimit.js +6 -3
- package/dist/lib/template.js +8 -1
- package/dist/lib/webhook-path.d.ts +11 -0
- package/dist/lib/webhook-path.js +42 -0
- package/dist/net.d.ts +189 -17
- package/dist/net.js +388 -142
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/package.json +13 -6
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An instant the caller supplies, read the same way on every machine.
|
|
3
|
+
*
|
|
4
|
+
* Every clock value in this package is an argument, so that the same
|
|
5
|
+
* question gets the same answer wherever it is asked. `new Date(text)` broke
|
|
6
|
+
* that for one spelling: an ISO-8601 date-time with no offset
|
|
7
|
+
* (`2026-09-17T23:30:00`) is read by ECMAScript as the HOST's local time, and
|
|
8
|
+
* a date alone as UTC midnight. The same QuietHours question was inside the
|
|
9
|
+
* window under UTC and outside it under Asia/Tokyo, and the same EmailCompose
|
|
10
|
+
* call wrote a different Date header and Message-ID.
|
|
11
|
+
*
|
|
12
|
+
* So an instant must say where it is, and the WHOLE string is read by a
|
|
13
|
+
* grammar here, never handed to `Date.parse`. Checking only that the text
|
|
14
|
+
* ended in something offset-shaped was not enough: JavaScriptCore's legacy
|
|
15
|
+
* parser reads `Sep 17 2026-23:30` as 23:30 host time and the trailing
|
|
16
|
+
* `-23:30` as noise, so it passed the check and still moved with the host
|
|
17
|
+
* zone (net review of 0.7.1). Two families are accepted, each with a
|
|
18
|
+
* mandatory zone:
|
|
19
|
+
*
|
|
20
|
+
* - ISO-8601 / RFC 3339: `2026-09-17T09:30:00Z`, `2026-09-17T18:30+09:00`,
|
|
21
|
+
* `2026-09-17T02:30:00.250-0700` (a space may stand for the `T`),
|
|
22
|
+
* `T24:00:00`, the end of the day, and a six-digit signed year
|
|
23
|
+
* (`+002026-…`).
|
|
24
|
+
* - Written dates, as an email Date header and JavaScript print them:
|
|
25
|
+
* `Thu, 17 Sep 2026 09:30:00 +0000`, `17 Sep 2026 09:30 GMT`,
|
|
26
|
+
* `Thursday, 17 September 2026 09:30:00 GMT`, `Sep 17, 2026 09:30:00 UTC`,
|
|
27
|
+
* and `Date.prototype.toString()`'s `Thu Sep 17 2026 09:30:00 GMT+0900
|
|
28
|
+
* (Japan Standard Time)`. Day and month names may be short or long. The
|
|
29
|
+
* weekday is optional and, as mail readers do, not checked against the
|
|
30
|
+
* date.
|
|
31
|
+
*
|
|
32
|
+
* A zone is `Z`, `UT`, `UTC`, `GMT`, a numeric offset (`+09:00`, `-0700`,
|
|
33
|
+
* `GMT+0900`), or one of the US zones RFC 5322 §4.3 defines with a fixed
|
|
34
|
+
* offset (EST, EDT, CST, CDT, MST, MDT, PST, PDT). 0.7.0's `new Date(text)`
|
|
35
|
+
* read every one of these the same way on every host, so refusing them
|
|
36
|
+
* broke calls that were never host-dependent (net regression review). Any
|
|
37
|
+
* other abbreviation (`CET`, `IST`) names different zones in different
|
|
38
|
+
* places, and is refused.
|
|
39
|
+
*
|
|
40
|
+
* Anything else is refused with the reason. Reading an offset-less time in
|
|
41
|
+
* some other zone (the schedule's, say) is not done: it would be a guess
|
|
42
|
+
* about what the caller meant.
|
|
43
|
+
*/
|
|
44
|
+
/** The epoch milliseconds `text` names, or undefined when it is not one of the spellings above. */
|
|
45
|
+
export declare function readZonedInstant(text: string): number | undefined;
|
|
46
|
+
export type ParsedInstant = {
|
|
47
|
+
readonly ok: true;
|
|
48
|
+
readonly ms: number;
|
|
49
|
+
} | {
|
|
50
|
+
readonly ok: false;
|
|
51
|
+
readonly message: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The epoch milliseconds `text` names, or a refusal that quotes it (clipped)
|
|
55
|
+
* and gives an example. `example` is the spelling the message suggests.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseOffsetInstant(text: string, example?: string): ParsedInstant;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An instant the caller supplies, read the same way on every machine.
|
|
3
|
+
*
|
|
4
|
+
* Every clock value in this package is an argument, so that the same
|
|
5
|
+
* question gets the same answer wherever it is asked. `new Date(text)` broke
|
|
6
|
+
* that for one spelling: an ISO-8601 date-time with no offset
|
|
7
|
+
* (`2026-09-17T23:30:00`) is read by ECMAScript as the HOST's local time, and
|
|
8
|
+
* a date alone as UTC midnight. The same QuietHours question was inside the
|
|
9
|
+
* window under UTC and outside it under Asia/Tokyo, and the same EmailCompose
|
|
10
|
+
* call wrote a different Date header and Message-ID.
|
|
11
|
+
*
|
|
12
|
+
* So an instant must say where it is, and the WHOLE string is read by a
|
|
13
|
+
* grammar here, never handed to `Date.parse`. Checking only that the text
|
|
14
|
+
* ended in something offset-shaped was not enough: JavaScriptCore's legacy
|
|
15
|
+
* parser reads `Sep 17 2026-23:30` as 23:30 host time and the trailing
|
|
16
|
+
* `-23:30` as noise, so it passed the check and still moved with the host
|
|
17
|
+
* zone (net review of 0.7.1). Two families are accepted, each with a
|
|
18
|
+
* mandatory zone:
|
|
19
|
+
*
|
|
20
|
+
* - ISO-8601 / RFC 3339: `2026-09-17T09:30:00Z`, `2026-09-17T18:30+09:00`,
|
|
21
|
+
* `2026-09-17T02:30:00.250-0700` (a space may stand for the `T`),
|
|
22
|
+
* `T24:00:00`, the end of the day, and a six-digit signed year
|
|
23
|
+
* (`+002026-…`).
|
|
24
|
+
* - Written dates, as an email Date header and JavaScript print them:
|
|
25
|
+
* `Thu, 17 Sep 2026 09:30:00 +0000`, `17 Sep 2026 09:30 GMT`,
|
|
26
|
+
* `Thursday, 17 September 2026 09:30:00 GMT`, `Sep 17, 2026 09:30:00 UTC`,
|
|
27
|
+
* and `Date.prototype.toString()`'s `Thu Sep 17 2026 09:30:00 GMT+0900
|
|
28
|
+
* (Japan Standard Time)`. Day and month names may be short or long. The
|
|
29
|
+
* weekday is optional and, as mail readers do, not checked against the
|
|
30
|
+
* date.
|
|
31
|
+
*
|
|
32
|
+
* A zone is `Z`, `UT`, `UTC`, `GMT`, a numeric offset (`+09:00`, `-0700`,
|
|
33
|
+
* `GMT+0900`), or one of the US zones RFC 5322 §4.3 defines with a fixed
|
|
34
|
+
* offset (EST, EDT, CST, CDT, MST, MDT, PST, PDT). 0.7.0's `new Date(text)`
|
|
35
|
+
* read every one of these the same way on every host, so refusing them
|
|
36
|
+
* broke calls that were never host-dependent (net regression review). Any
|
|
37
|
+
* other abbreviation (`CET`, `IST`) names different zones in different
|
|
38
|
+
* places, and is refused.
|
|
39
|
+
*
|
|
40
|
+
* Anything else is refused with the reason. Reading an offset-less time in
|
|
41
|
+
* some other zone (the schedule's, say) is not done: it would be a guess
|
|
42
|
+
* about what the caller meant.
|
|
43
|
+
*/
|
|
44
|
+
// The same grammar as tool-http's lib/http-date.ts reads a Retry-After date
|
|
45
|
+
// with a zone in. The packages do not depend on each other, and neither
|
|
46
|
+
// should grow a dependency for one small reader: change both together.
|
|
47
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
48
|
+
const LONG_MONTHS = [
|
|
49
|
+
"january",
|
|
50
|
+
"february",
|
|
51
|
+
"march",
|
|
52
|
+
"april",
|
|
53
|
+
"may",
|
|
54
|
+
"june",
|
|
55
|
+
"july",
|
|
56
|
+
"august",
|
|
57
|
+
"september",
|
|
58
|
+
"october",
|
|
59
|
+
"november",
|
|
60
|
+
"december",
|
|
61
|
+
];
|
|
62
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
63
|
+
const LONG_DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
64
|
+
/** Minutes east of UTC for each zone name read here (RFC 5322 §3.3 and §4.3). */
|
|
65
|
+
const NAMED_ZONES = new Map([
|
|
66
|
+
["Z", 0],
|
|
67
|
+
["UT", 0],
|
|
68
|
+
["UTC", 0],
|
|
69
|
+
["GMT", 0],
|
|
70
|
+
["EST", -300],
|
|
71
|
+
["EDT", -240],
|
|
72
|
+
["CST", -360],
|
|
73
|
+
["CDT", -300],
|
|
74
|
+
["MST", -420],
|
|
75
|
+
["MDT", -360],
|
|
76
|
+
["PST", -480],
|
|
77
|
+
["PDT", -420],
|
|
78
|
+
]);
|
|
79
|
+
// Anchored, bounded and without nested quantifiers: each is linear. The
|
|
80
|
+
// input is also capped before either runs.
|
|
81
|
+
const ISO = /^((?:[+-]\d{2})?\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?([Zz]|[+-]\d{2}:?\d{2})$/;
|
|
82
|
+
/**
|
|
83
|
+
* A written date: optional weekday, then day-first (`17 Sep 2026`,
|
|
84
|
+
* `17-Sep-2026`) or month-first (`Sep 17, 2026`, `Sep 17 2026`), a time,
|
|
85
|
+
* a zone, and an optional parenthesised zone name after it.
|
|
86
|
+
*/
|
|
87
|
+
const WRITTEN = /^(?:([A-Za-z]{3,9}),?\s+)?(?:(\d{1,2})[ -]([A-Za-z]{3,9})\.?[ -](\d{4})|([A-Za-z]{3,9})\.?\s(\d{1,2}),?\s(\d{4}))\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*((?:GMT|UTC)?[+-]\d{2}:?\d{2}|UTC|UT|GMT|Z|[ECMP][SD]T)(?:\s+\([^()]{0,64}\))?$/i;
|
|
88
|
+
const MAX_LENGTH = 128;
|
|
89
|
+
/** Minutes east of UTC for a zone {@link ISO} or {@link WRITTEN} matched; undefined when out of range. */
|
|
90
|
+
function offsetMinutes(zone) {
|
|
91
|
+
const upper = zone.toUpperCase();
|
|
92
|
+
const named = NAMED_ZONES.get(upper);
|
|
93
|
+
if (named !== undefined)
|
|
94
|
+
return named;
|
|
95
|
+
const numeric = upper.startsWith("GMT") || upper.startsWith("UTC") ? upper.slice(3) : upper;
|
|
96
|
+
const sign = numeric[0] === "-" ? -1 : 1;
|
|
97
|
+
const digits = numeric.slice(1).replace(":", "");
|
|
98
|
+
const hours = Number(digits.slice(0, 2));
|
|
99
|
+
const minutes = Number(digits.slice(2, 4));
|
|
100
|
+
if (hours > 23 || minutes > 59)
|
|
101
|
+
return undefined;
|
|
102
|
+
return sign * (hours * 60 + minutes);
|
|
103
|
+
}
|
|
104
|
+
/** A month's index from its short or long English name, or -1. */
|
|
105
|
+
function monthIndex(name) {
|
|
106
|
+
const lower = name.toLowerCase();
|
|
107
|
+
return lower.length === 3 ? MONTHS.indexOf(lower) : LONG_MONTHS.indexOf(lower);
|
|
108
|
+
}
|
|
109
|
+
/** The instant, or undefined when a field is out of range (31 Nov, 25:00). */
|
|
110
|
+
function instant(year, month, day, hour, minute, second, fraction, zone) {
|
|
111
|
+
const offset = offsetMinutes(zone);
|
|
112
|
+
if (offset === undefined || month < 0 || month > 11)
|
|
113
|
+
return undefined;
|
|
114
|
+
// 24:00:00 is ISO-8601's end of the day, the next day's 00:00.
|
|
115
|
+
const endOfDay = hour === 24 && minute === 0 && second === 0 && !/[1-9]/.test(fraction ?? "");
|
|
116
|
+
if ((hour > 23 && !endOfDay) || minute > 59 || second > 60)
|
|
117
|
+
return undefined;
|
|
118
|
+
const midnight = utc(year, month, day, 0, 0, 0, 0);
|
|
119
|
+
const check = new Date(midnight);
|
|
120
|
+
if (check.getUTCMonth() !== month || check.getUTCDate() !== day)
|
|
121
|
+
return undefined;
|
|
122
|
+
// A leap second (:60) names the same instant as the next minute's :00,
|
|
123
|
+
// which is what the arithmetic gives for 60.
|
|
124
|
+
const ms = fraction === undefined ? 0 : Math.floor(Number(`0.${fraction}`) * 1000);
|
|
125
|
+
return utc(year, month, day, hour, minute, second, ms) - offset * 60_000;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `Date.UTC` without its one surprise: it reads a year from 0 to 99 as
|
|
129
|
+
* 1900 plus that, so `0026-01-01T00:00:00Z` became 1926.
|
|
130
|
+
*/
|
|
131
|
+
function utc(year, month, day, hour, minute, second, ms) {
|
|
132
|
+
const d = new Date(0);
|
|
133
|
+
d.setUTCFullYear(year, month, day);
|
|
134
|
+
return d.getTime() + ((hour * 60 + minute) * 60 + second) * 1000 + ms;
|
|
135
|
+
}
|
|
136
|
+
/** The epoch milliseconds `text` names, or undefined when it is not one of the spellings above. */
|
|
137
|
+
export function readZonedInstant(text) {
|
|
138
|
+
const t = text.trim();
|
|
139
|
+
if (t.length > MAX_LENGTH)
|
|
140
|
+
return undefined;
|
|
141
|
+
const iso = t.match(ISO);
|
|
142
|
+
if (iso !== null) {
|
|
143
|
+
// ECMAScript's rule: year zero has one spelling, 0000, not -000000.
|
|
144
|
+
if (iso[1] === "-000000")
|
|
145
|
+
return undefined;
|
|
146
|
+
return instant(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), Number(iso[4]), Number(iso[5]), Number(iso[6] ?? 0), iso[7], iso[8]);
|
|
147
|
+
}
|
|
148
|
+
const w = t.match(WRITTEN);
|
|
149
|
+
if (w !== null) {
|
|
150
|
+
const weekday = w[1]?.toLowerCase();
|
|
151
|
+
if (weekday !== undefined && !DAYS.includes(weekday) && !LONG_DAYS.includes(weekday)) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const dayFirst = w[2] !== undefined;
|
|
155
|
+
return instant(Number(dayFirst ? w[4] : w[7]), monthIndex((dayFirst ? w[3] : w[5])), Number(dayFirst ? w[2] : w[6]), Number(w[8]), Number(w[9]), Number(w[10] ?? 0), undefined, w[11]);
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Whether `text` carries something that names a zone: a numeric offset after
|
|
161
|
+
* a digit or a space, a `Z` after a digit, `GMT`/`UTC`/`UT`, or a trailing
|
|
162
|
+
* abbreviation. Only for wording a refusal; linear, on capped input.
|
|
163
|
+
*/
|
|
164
|
+
function namesAZone(text) {
|
|
165
|
+
const t = text.slice(0, 200);
|
|
166
|
+
return (/[\d\s][+-]\d{2}:?\d{2}\b/.test(t) ||
|
|
167
|
+
/\d[Zz]$/.test(t) ||
|
|
168
|
+
/\b(?:GMT|UTC|UT)\b/i.test(t) ||
|
|
169
|
+
/\b[A-Z]{3,5}(?:\s*\([^()]*\))?$/.test(t));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The epoch milliseconds `text` names, or a refusal that quotes it (clipped)
|
|
173
|
+
* and gives an example. `example` is the spelling the message suggests.
|
|
174
|
+
*/
|
|
175
|
+
export function parseOffsetInstant(text, example = "2026-09-17T09:30:00Z") {
|
|
176
|
+
const trimmed = text.trim();
|
|
177
|
+
const shown = trimmed.length > 80 ? `${trimmed.slice(0, 80)}…` : trimmed;
|
|
178
|
+
const ms = readZonedInstant(trimmed);
|
|
179
|
+
if (ms !== undefined)
|
|
180
|
+
return { ok: true, ms };
|
|
181
|
+
const spellings = `write it as e.g. ${example} (ISO-8601) or Thu, 17 Sep 2026 09:30:00 +0000 (RFC 5322)`;
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
message: namesAZone(trimmed)
|
|
185
|
+
? `"${shown}" is not an instant in a spelling this tool reads, or one of its fields is out of range — ${spellings}. A zone must be Z, UT, UTC, GMT, a numeric offset, or one of the US zones RFC 5322 defines (EST, EDT, CST, CDT, MST, MDT, PST, PDT); other abbreviations name different zones in different places`
|
|
186
|
+
: `"${shown}" is not an instant with a UTC offset — ${spellings}; a time without an offset means the host's local time, which differs between machines`,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/dist/lib/preflight.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* that is fine are different answers, and only one of them means the
|
|
24
24
|
* message is ready.
|
|
25
25
|
*/
|
|
26
|
+
import { parseOffsetInstant } from "./instant";
|
|
26
27
|
import { isMessageId, isReservedHeaderName, isValidAddress, isValidHeaderName } from "./mime";
|
|
27
28
|
import { templatePlaceholders } from "./template";
|
|
28
29
|
/** Locale-free order, so two runs list the same faults in the same place. */
|
|
@@ -171,18 +172,15 @@ function checkEnvelope(draft) {
|
|
|
171
172
|
};
|
|
172
173
|
}
|
|
173
174
|
function checkDate(draft) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
detail: `"${draft.date}" is not an instant - use ISO-8601 with an offset, e.g. 2026-09-17T09:30:00Z`,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
175
|
+
// The same reader EmailSend and EmailCompose build the header with, so a
|
|
176
|
+
// date the preflight passes is a date the send accepts.
|
|
177
|
+
const instant = parseOffsetInstant(draft.date);
|
|
178
|
+
if (!instant.ok)
|
|
179
|
+
return { check: "date", status: "fail", detail: instant.message };
|
|
182
180
|
return {
|
|
183
181
|
check: "date",
|
|
184
182
|
status: "pass",
|
|
185
|
-
detail: `the Date header will read ${
|
|
183
|
+
detail: `the Date header will read ${new Date(instant.ms).toISOString()}`,
|
|
186
184
|
};
|
|
187
185
|
}
|
|
188
186
|
function checkHeaders(draft) {
|
package/dist/lib/quiet.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export type QuietError = {
|
|
|
44
44
|
};
|
|
45
45
|
/** Minutes past local midnight, or `null` when `text` is not `HH:MM`. */
|
|
46
46
|
export declare function parseClock(text: string): number | null;
|
|
47
|
+
/** Test-only: how many formatters the cache holds. */
|
|
48
|
+
export declare function __quietFormatterCountForTest(): number;
|
|
47
49
|
/** Validate a schedule up front, so a bad window is one error and not silence. */
|
|
48
50
|
export declare function validateSchedule(schedule: QuietSchedule): string | null;
|
|
49
51
|
/**
|
|
@@ -56,4 +58,4 @@ export declare function validateSchedule(schedule: QuietSchedule): string | null
|
|
|
56
58
|
* weekly schedule; a schedule that blacks out more than nine consecutive
|
|
57
59
|
* days is reported as such rather than answered with a guess.
|
|
58
60
|
*/
|
|
59
|
-
export declare function quietDecision(
|
|
61
|
+
export declare function quietDecision(given: QuietSchedule, nowMs: number): QuietDecision | QuietError;
|
package/dist/lib/quiet.js
CHANGED
|
@@ -28,25 +28,55 @@ export function parseClock(text) {
|
|
|
28
28
|
return null;
|
|
29
29
|
return hours * 60 + minutes;
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The zone's canonical name: `america/new_york` is `America/New_York`,
|
|
33
|
+
* `-0800` is `-08:00`. Throws a RangeError for a zone the runtime does not
|
|
34
|
+
* know. The formatter it builds to ask is thrown away, never cached.
|
|
35
|
+
*/
|
|
36
|
+
function canonicalZone(timeZone) {
|
|
37
|
+
return new Intl.DateTimeFormat("en-US", { timeZone }).resolvedOptions().timeZone;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Formatters by CANONICAL zone name, least recently used first, at most
|
|
41
|
+
* {@link MAX_FORMATTERS} of them. Building one per call is the slow path,
|
|
42
|
+
* but a cache keyed by whatever the caller spelled is unbounded: Intl
|
|
43
|
+
* accepts any casing of an IANA name and several spellings of every offset,
|
|
44
|
+
* each formatter holds tens of kilobytes, and 0.7.0 kept every one for the
|
|
45
|
+
* life of the process (20 000 case variants of one zone cost about 730 MB).
|
|
46
|
+
* Canonical names alone are not a bound either — the offset zones number in
|
|
47
|
+
* the thousands — so the cache is also capped.
|
|
48
|
+
*/
|
|
32
49
|
const formatters = new Map();
|
|
50
|
+
const MAX_FORMATTERS = 64;
|
|
51
|
+
/** `timeZone` must already be canonical (see {@link canonicalZone}). */
|
|
33
52
|
function formatterFor(timeZone) {
|
|
34
53
|
let formatter = formatters.get(timeZone);
|
|
35
|
-
if (formatter
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
hourCycle: "h23",
|
|
39
|
-
year: "numeric",
|
|
40
|
-
month: "2-digit",
|
|
41
|
-
day: "2-digit",
|
|
42
|
-
hour: "2-digit",
|
|
43
|
-
minute: "2-digit",
|
|
44
|
-
second: "2-digit",
|
|
45
|
-
});
|
|
54
|
+
if (formatter !== undefined) {
|
|
55
|
+
// Most recently used moves to the back.
|
|
56
|
+
formatters.delete(timeZone);
|
|
46
57
|
formatters.set(timeZone, formatter);
|
|
58
|
+
return formatter;
|
|
59
|
+
}
|
|
60
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
61
|
+
timeZone,
|
|
62
|
+
hourCycle: "h23",
|
|
63
|
+
year: "numeric",
|
|
64
|
+
month: "2-digit",
|
|
65
|
+
day: "2-digit",
|
|
66
|
+
hour: "2-digit",
|
|
67
|
+
minute: "2-digit",
|
|
68
|
+
second: "2-digit",
|
|
69
|
+
});
|
|
70
|
+
while (formatters.size >= MAX_FORMATTERS) {
|
|
71
|
+
formatters.delete(formatters.keys().next().value);
|
|
47
72
|
}
|
|
73
|
+
formatters.set(timeZone, formatter);
|
|
48
74
|
return formatter;
|
|
49
75
|
}
|
|
76
|
+
/** Test-only: how many formatters the cache holds. */
|
|
77
|
+
export function __quietFormatterCountForTest() {
|
|
78
|
+
return formatters.size;
|
|
79
|
+
}
|
|
50
80
|
/** The wall-clock reading of an instant in a timezone. */
|
|
51
81
|
function zonedParts(instant, timeZone) {
|
|
52
82
|
const parts = formatterFor(timeZone).formatToParts(new Date(instant));
|
|
@@ -97,7 +127,7 @@ function isoDate(z) {
|
|
|
97
127
|
/** Validate a schedule up front, so a bad window is one error and not silence. */
|
|
98
128
|
export function validateSchedule(schedule) {
|
|
99
129
|
try {
|
|
100
|
-
|
|
130
|
+
canonicalZone(schedule.timezone);
|
|
101
131
|
}
|
|
102
132
|
catch {
|
|
103
133
|
return `"${schedule.timezone}" is not a timezone this runtime knows — use an IANA name such as Europe/Berlin`;
|
|
@@ -170,10 +200,13 @@ const SEARCH_DAYS = 9;
|
|
|
170
200
|
* weekly schedule; a schedule that blacks out more than nine consecutive
|
|
171
201
|
* days is reported as such rather than answered with a guess.
|
|
172
202
|
*/
|
|
173
|
-
export function quietDecision(
|
|
174
|
-
const invalid = validateSchedule(
|
|
203
|
+
export function quietDecision(given, nowMs) {
|
|
204
|
+
const invalid = validateSchedule(given);
|
|
175
205
|
if (invalid !== null)
|
|
176
206
|
return { error: invalid };
|
|
207
|
+
// Every lookup below goes through the cache, so it is keyed by the zone,
|
|
208
|
+
// not by how this call happened to spell it.
|
|
209
|
+
const schedule = { ...given, timezone: canonicalZone(given.timezone) };
|
|
177
210
|
const z = zonedParts(nowMs, schedule.timezone);
|
|
178
211
|
const local = {
|
|
179
212
|
localTime: `${pad(z.hour, 2)}:${pad(z.minute, 2)}`,
|
package/dist/lib/ratelimit.js
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* no longer refuse anything, so nothing is lost by forgetting them.
|
|
23
23
|
*/
|
|
24
24
|
function prune(state, nowMs, windowMs, keep) {
|
|
25
|
-
|
|
25
|
+
// A record indexed by a caller's string has no prototype: on a plain `{}`,
|
|
26
|
+
// `kept["__proto__"] = entry` sets the object's prototype instead of adding
|
|
27
|
+
// the key, JSON drops it, and that key was never limited at all.
|
|
28
|
+
const kept = Object.create(null);
|
|
26
29
|
const pruned = [];
|
|
27
30
|
for (const name of Object.keys(state).sort()) {
|
|
28
31
|
const entry = state[name];
|
|
@@ -40,7 +43,7 @@ export function rateLimitGate(options) {
|
|
|
40
43
|
const windowMs = Math.max(1, options.windowMs);
|
|
41
44
|
const state = options.state ?? {};
|
|
42
45
|
const { kept, pruned } = prune(state, options.nowMs, windowMs, options.key);
|
|
43
|
-
const existing = kept[options.key];
|
|
46
|
+
const existing = Object.hasOwn(kept, options.key) ? kept[options.key] : undefined;
|
|
44
47
|
const inWindow = existing !== undefined && options.nowMs - existing.windowStart < windowMs;
|
|
45
48
|
const current = inWindow ? existing : undefined;
|
|
46
49
|
const count = current?.count ?? 0;
|
|
@@ -68,7 +71,7 @@ export function rateLimitGate(options) {
|
|
|
68
71
|
pruned,
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
|
-
const next =
|
|
74
|
+
const next = Object.assign(Object.create(null), kept);
|
|
72
75
|
next[options.key] = {
|
|
73
76
|
windowStart: current?.windowStart ?? options.nowMs,
|
|
74
77
|
count: count + 1,
|
package/dist/lib/template.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { escapeFor } from "./blocks";
|
|
2
2
|
/** `{{ name }}` or `{{ a.b.c }}`; whitespace inside the braces is ignored. */
|
|
3
3
|
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_][A-Za-z0-9_.]*)\s*\}\}/g;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Read a dotted path out of the data object: own properties only. An
|
|
6
|
+
* inherited one is not data the caller supplied: `{{__proto__}}` read
|
|
7
|
+
* Object.prototype and rendered `{}` where the rule is that a missing value
|
|
8
|
+
* is an error rather than a hole (net review, beside C208).
|
|
9
|
+
*/
|
|
5
10
|
function lookup(data, path) {
|
|
6
11
|
let current = data;
|
|
7
12
|
for (const segment of path.split(".")) {
|
|
8
13
|
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
9
14
|
return undefined;
|
|
10
15
|
}
|
|
16
|
+
if (!Object.hasOwn(current, segment))
|
|
17
|
+
return undefined;
|
|
11
18
|
current = current[segment];
|
|
12
19
|
}
|
|
13
20
|
return current;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a URL path is a Teams incoming webhook's, whose path is the
|
|
3
|
+
* credential: what `/^\/webhookb2\/[^/]+@[^/]+\/IncomingWebhook\/.+/i`
|
|
4
|
+
* answered, read a segment at a time. The segment after `/webhookb2/` must
|
|
5
|
+
* hold an `@` with a character on each side, `/IncomingWebhook/` must follow
|
|
6
|
+
* it, and a character that is not a line break after that. The pattern's
|
|
7
|
+
* `[^/]+@` overlapped the `@`, so it tried every `@` of a run and rescanned
|
|
8
|
+
* the rest of the segment from each: quadratic in a path the call wrote,
|
|
9
|
+
* before the allow-list was asked.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isTeamsWebhookPath(path: string): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Whether `text` holds `lower` at `at`, an ASCII letter in either case as the `i` flag matches. */
|
|
2
|
+
function caseFoldedAt(text, at, lower) {
|
|
3
|
+
if (at + lower.length > text.length)
|
|
4
|
+
return false;
|
|
5
|
+
for (let i = 0; i < lower.length; i++) {
|
|
6
|
+
const want = lower.charCodeAt(i);
|
|
7
|
+
const got = text.charCodeAt(at + i);
|
|
8
|
+
if (got !== want && !(want >= 0x61 && want <= 0x7a && got === want - 0x20))
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
/** A code unit `.` does not match without the `s` flag. */
|
|
14
|
+
function isLineBreak(c) {
|
|
15
|
+
return c === 0x0a || c === 0x0d || c === 0x2028 || c === 0x2029;
|
|
16
|
+
}
|
|
17
|
+
const TEAMS_PREFIX = "/webhookb2/";
|
|
18
|
+
const TEAMS_MARKER = "/incomingwebhook/";
|
|
19
|
+
/**
|
|
20
|
+
* Whether a URL path is a Teams incoming webhook's, whose path is the
|
|
21
|
+
* credential: what `/^\/webhookb2\/[^/]+@[^/]+\/IncomingWebhook\/.+/i`
|
|
22
|
+
* answered, read a segment at a time. The segment after `/webhookb2/` must
|
|
23
|
+
* hold an `@` with a character on each side, `/IncomingWebhook/` must follow
|
|
24
|
+
* it, and a character that is not a line break after that. The pattern's
|
|
25
|
+
* `[^/]+@` overlapped the `@`, so it tried every `@` of a run and rescanned
|
|
26
|
+
* the rest of the segment from each: quadratic in a path the call wrote,
|
|
27
|
+
* before the allow-list was asked.
|
|
28
|
+
*/
|
|
29
|
+
export function isTeamsWebhookPath(path) {
|
|
30
|
+
if (!caseFoldedAt(path, 0, TEAMS_PREFIX))
|
|
31
|
+
return false;
|
|
32
|
+
const segmentEnd = path.indexOf("/", TEAMS_PREFIX.length);
|
|
33
|
+
if (segmentEnd === -1)
|
|
34
|
+
return false;
|
|
35
|
+
const at = path.indexOf("@", TEAMS_PREFIX.length + 1);
|
|
36
|
+
if (at === -1 || at > segmentEnd - 2)
|
|
37
|
+
return false;
|
|
38
|
+
if (!caseFoldedAt(path, segmentEnd, TEAMS_MARKER))
|
|
39
|
+
return false;
|
|
40
|
+
const rest = segmentEnd + TEAMS_MARKER.length;
|
|
41
|
+
return rest < path.length && !isLineBreak(path.charCodeAt(rest));
|
|
42
|
+
}
|