@37signals/basecamp 0.18.0 → 0.19.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.
Files changed (82) hide show
  1. package/README.md +94 -2
  2. package/dist/client.d.ts +6 -3
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +11 -5
  5. package/dist/client.js.map +1 -1
  6. package/dist/download.js +1 -1
  7. package/dist/errors.d.ts +10 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +42 -13
  10. package/dist/errors.js.map +1 -1
  11. package/dist/generated/metadata.d.ts +1 -2
  12. package/dist/generated/metadata.d.ts.map +1 -1
  13. package/dist/generated/metadata.js +47 -1
  14. package/dist/generated/metadata.js.map +1 -1
  15. package/dist/generated/metadata.ts +48 -3
  16. package/dist/generated/openapi-stripped.json +772 -16
  17. package/dist/generated/path-mapping.d.ts.map +1 -1
  18. package/dist/generated/path-mapping.js +7 -4
  19. package/dist/generated/path-mapping.js.map +1 -1
  20. package/dist/generated/path-mapping.ts +8 -5
  21. package/dist/generated/person-id-sites.d.ts +2 -0
  22. package/dist/generated/person-id-sites.d.ts.map +1 -0
  23. package/dist/generated/person-id-sites.js +689 -0
  24. package/dist/generated/person-id-sites.js.map +1 -0
  25. package/dist/generated/person-id-sites.ts +690 -0
  26. package/dist/generated/schema.d.ts +643 -12
  27. package/dist/generated/services/event-feed.d.ts +92 -0
  28. package/dist/generated/services/event-feed.d.ts.map +1 -0
  29. package/dist/generated/services/event-feed.js +80 -0
  30. package/dist/generated/services/event-feed.js.map +1 -0
  31. package/dist/generated/services/event-feed.ts +153 -0
  32. package/dist/generated/services/gauges.d.ts +7 -3
  33. package/dist/generated/services/gauges.d.ts.map +1 -1
  34. package/dist/generated/services/gauges.js +4 -1
  35. package/dist/generated/services/gauges.js.map +1 -1
  36. package/dist/generated/services/gauges.ts +10 -3
  37. package/dist/generated/services/index.d.ts +1 -0
  38. package/dist/generated/services/index.d.ts.map +1 -1
  39. package/dist/generated/services/index.js +1 -0
  40. package/dist/generated/services/index.js.map +1 -1
  41. package/dist/generated/services/index.ts +1 -0
  42. package/dist/index.d.ts +6 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +11 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/person-id.d.ts +133 -0
  47. package/dist/person-id.d.ts.map +1 -0
  48. package/dist/person-id.js +163 -0
  49. package/dist/person-id.js.map +1 -0
  50. package/dist/retry.d.ts +2 -1
  51. package/dist/retry.d.ts.map +1 -1
  52. package/dist/retry.js +21 -11
  53. package/dist/retry.js.map +1 -1
  54. package/dist/services/base.d.ts +35 -0
  55. package/dist/services/base.d.ts.map +1 -1
  56. package/dist/services/base.js +387 -55
  57. package/dist/services/base.js.map +1 -1
  58. package/dist/services/comments-extensions.d.ts +89 -0
  59. package/dist/services/comments-extensions.d.ts.map +1 -0
  60. package/dist/services/comments-extensions.js +166 -0
  61. package/dist/services/comments-extensions.js.map +1 -0
  62. package/dist/services/mentions.d.ts +255 -0
  63. package/dist/services/mentions.d.ts.map +1 -0
  64. package/dist/services/mentions.js +1431 -0
  65. package/dist/services/mentions.js.map +1 -0
  66. package/dist/services/merge-safe.d.ts +13 -5
  67. package/dist/services/merge-safe.d.ts.map +1 -1
  68. package/dist/services/merge-safe.js +71 -13
  69. package/dist/services/merge-safe.js.map +1 -1
  70. package/dist/services/recordings-extensions.d.ts +354 -0
  71. package/dist/services/recordings-extensions.d.ts.map +1 -0
  72. package/dist/services/recordings-extensions.js +1940 -0
  73. package/dist/services/recordings-extensions.js.map +1 -0
  74. package/package.json +4 -4
  75. package/src/generated/metadata.ts +48 -3
  76. package/src/generated/openapi-stripped.json +772 -16
  77. package/src/generated/path-mapping.ts +8 -5
  78. package/src/generated/person-id-sites.ts +690 -0
  79. package/src/generated/schema.d.ts +643 -12
  80. package/src/generated/services/event-feed.ts +153 -0
  81. package/src/generated/services/gauges.ts +10 -3
  82. package/src/generated/services/index.ts +1 -0
@@ -0,0 +1,1431 @@
1
+ /**
2
+ * Mention helpers over Basecamp rich text.
3
+ *
4
+ * A mention in Basecamp rich text is a `<bc-attachment>` whose `sgid` attribute
5
+ * is the mentioned person's `attachable_sgid` (doc/api/sections/rich_text.md,
6
+ * "Inserting a mention"). BC3 renders the same tag back with
7
+ * `content-type="application/vnd.basecamp.mention"` and an avatar figure inside
8
+ * it, but the sgid is the only part of the markup that names the person on both
9
+ * the write and the read side, so every helper here works from it. The module
10
+ * exports four, not the two this list carried while it grew:
11
+ *
12
+ * - {@link mentionedPersonIds} reads the person ids a rich text names, by
13
+ * decoding the sgid of every `<bc-attachment>` and keeping the ones that
14
+ * point at a Person.
15
+ * - {@link personIdFromSGID} is the decode under it, for a single sgid.
16
+ * - {@link mentionMarkup} writes the tag for a person, from their
17
+ * `attachable_sgid`.
18
+ * - {@link withMentions} places those tags inside a rich text's first block,
19
+ * skipping a person the content already mentions by that exact sgid.
20
+ *
21
+ * {@link unescapeEntities} and {@link namedEntityNames} are exported for the
22
+ * differential tests and are not part of the SDK's surface.
23
+ *
24
+ * An `attachable_sgid` is a Rails SignedGlobalID: a base64 payload, then `--`,
25
+ * then an HMAC only BC3 can verify. The payload is an envelope carrying the
26
+ * global id — `gid://bc3/Person/1049715915` — as a string, and that string is
27
+ * what these helpers read. They do not (and cannot) verify the signature; what
28
+ * they decode is the same person id BC3 renders into the mention's avatar, read
29
+ * off content the API already served, and a caller that needs the id verified
30
+ * reads the person back through `people.get`.
31
+ *
32
+ * That sets a trust boundary between the two sides. READING —
33
+ * {@link mentionedPersonIds}, {@link personIdFromSGID} — describes what a text
34
+ * says it mentions, and unsigned is fine for description: the ids are reported,
35
+ * not acted on as proof. WRITING — {@link withMentions},
36
+ * `CommentsService.expandMentions` — never treats an unsigned id as proof that
37
+ * a valid mention already exists: a forged or stale sgid in caller-supplied
38
+ * content naming the right id would otherwise make the writer skip the
39
+ * authoritative people read and post a tag Basecamp will not honour, so the
40
+ * person is silently not mentioned. `CommentsService.expandMentions` therefore
41
+ * resolves every requested person through `people.get` and deduplicates only
42
+ * against the exact `attachable_sgid` string that read returned. The pure
43
+ * helpers beneath it — {@link withMentions}, {@link mentionMarkup} — take
44
+ * Person values the caller built and can only check that an sgid is
45
+ * well-formed and names the person it is given, never that it is authentic:
46
+ * hand them people the API returned, not people assembled from content. Do not
47
+ * reuse the read-side helpers to decide whether a write can be skipped.
48
+ *
49
+ * The markup is read as BC3 serves it: a sanitized tree of the tags
50
+ * doc/api/sections/rich_text.md allows, which has no raw-text elements. The tag
51
+ * walk skips comments and quoted attribute values but does not model `<script>`
52
+ * or `<style>` content, which BC3 strips on write; a caller reading mentions
53
+ * out of content it authored itself should not put a `bc-attachment` inside
54
+ * such an element and expect it ignored.
55
+ *
56
+ * The envelope is decoded structurally, never searched as bytes, so a Person
57
+ * gid that merely appears inside some other value — a Document gid built from
58
+ * one, a purpose string that looks like one — is not a mention, and the
59
+ * envelope's purpose must be `attachable`, the one BC3 accepts in rich text.
60
+ * Three envelopes are read: Rails' current Marshal layout
61
+ * `{"_rails" => {"data" => gid, "pur" => purpose}}`, the older Marshal layout
62
+ * `{"gid" => gid, "purpose" => …, "expires_at" => …}`, and the JSON spelling of
63
+ * either, which Rails' JSON message serializer emits.
64
+ */
65
+ import { Errors } from "../errors.js";
66
+ import { personIdNumber, scanPersonId } from "../person-id.js";
67
+ // =============================================================================
68
+ // Reading mentions
69
+ // =============================================================================
70
+ /**
71
+ * The ids of the people a rich text mentions: the Person named by the sgid of
72
+ * each `<bc-attachment>`, in document order, with repeats removed. Attachments
73
+ * that are not mentions — files, images, embeds — are skipped, as is any sgid
74
+ * that does not decode to a Person.
75
+ *
76
+ * This is the read side: a description of what the text says, from sgids whose
77
+ * signatures cannot be checked here. Report it; do not treat an id in it as
78
+ * proof that a valid mention exists (see the trust boundary above).
79
+ *
80
+ * Every `<bc-attachment>` in the text counts, including one inside a
81
+ * `<blockquote>`: BC3 notifies quoted mentions too, so the read matches what
82
+ * the server does with the write.
83
+ *
84
+ * An id a `number` cannot carry — a valid `int64` past
85
+ * `Number.MAX_SAFE_INTEGER` — is SKIPPED, like any other sgid this cannot
86
+ * resolve to a person.
87
+ *
88
+ * That is a real under-report, and it was briefly a throw for that reason: the
89
+ * count is the whole answer here, and dropping an id silently makes a short
90
+ * list indistinguishable from a genuinely shorter text, on a value that feeds
91
+ * `mentioned_person_ids` and decides who gets notified. Measured against the
92
+ * reference, a text naming `7, 9007199254740991, 9007199254740992,
93
+ * 9007199254740993, 9223372036854775807, 0009223372036854775807` gives Go 5 ids
94
+ * and gives this 2.
95
+ *
96
+ * THE THROW WAS WORSE, AND NOT BY A LITTLE. This function's input is rich text
97
+ * off the wire, and `recordingsExtensions.projectRecording` calls it unguarded
98
+ * on server-returned `content`. The sgid's signature is never verified here —
99
+ * `globalIDFromSGID` takes a bare unsigned envelope — so the id inside it is
100
+ * written by whoever wrote the comment. A throw therefore handed any author of
101
+ * a `<bc-attachment>` a way to fail `recordings.summarize()` outright: one
102
+ * crafted mention, and every other mention, the content, the title and the
103
+ * creator all go with it. Refusing to name one person is a smaller loss than
104
+ * refusing to read the recording, and the choice is not the SDK's to make on
105
+ * the caller's behalf when an adversary picks the input.
106
+ *
107
+ * It is also the reference's own failure mode: `MentionedPersonIDs`
108
+ * (`go/pkg/basecamp/mentions.go:82-92`) `continue`s on every sgid
109
+ * `PersonIDFromSGID` declines, and never fails the text. Go simply has fewer
110
+ * ids it must decline, because an `int64` fits its return type.
111
+ *
112
+ * `person-id.ts` argues the same trade the same way for the normalizer — one
113
+ * unrepresentable id must not discard every other record in the body — and this
114
+ * site is the one where the input is untrusted by construction, so it is the
115
+ * last place that argument should have been abandoned. The residual is recorded
116
+ * in SPEC §10 as an availability trade-off rather than hidden.
117
+ *
118
+ * A caller that must not under-report reads {@link readMentions} instead, which
119
+ * returns the same list alongside the ids it could not name. That is the
120
+ * distinguishable signal the skip would otherwise lack, and
121
+ * `recordings.summarize()` surfaces it as `unnameable_mention_ids`.
122
+ *
123
+ */
124
+ export function mentionedPersonIds(richText) {
125
+ return readMentions(richText).ids;
126
+ }
127
+ /**
128
+ * The mentions a rich text names, and the ones it names that cannot be reported
129
+ * as numbers.
130
+ *
131
+ * {@link mentionedPersonIds} is `readMentions(text).ids`. This is the same walk
132
+ * with the skipped ids kept rather than dropped, so a caller that must not
133
+ * under-report can tell "this text mentions two people" from "this text mentions
134
+ * two people I can name and one I cannot".
135
+ *
136
+ * `unnameable` holds each id as its CANONICAL DECIMAL STRING — no leading zeros,
137
+ * what `strconv.ParseInt` would return — in document order, deduplicated on that
138
+ * value the way the reference deduplicates on the `int64`. So `0009223372036854775807`
139
+ * and `9223372036854775807` are one entry, and `ids.length + unnameable.length`
140
+ * is the count the reference gives. A string because that is the whole point:
141
+ * these are exactly the ids no `number` can carry, so reporting them as numbers
142
+ * would round them into a neighbouring person — the failure this refuses to make.
143
+ * They are real ids, not malformed ones: an sgid that does not decode, or names
144
+ * something other than a Person, or carries a magnitude past `int64`, is not a
145
+ * mention in the reference either and appears in neither list.
146
+ *
147
+ * Nothing here verifies the sgid's signature, so an id in `unnameable` is as
148
+ * untrusted as one in `ids` — see the trust boundary above.
149
+ */
150
+ export function readMentions(richText) {
151
+ const ids = [];
152
+ const unnameable = [];
153
+ const seen = new Set();
154
+ const seenUnnameable = new Set();
155
+ for (const sgid of bcAttachmentSGIDs(richText)) {
156
+ const mentioned = parsePersonSGID(sgid);
157
+ if (mentioned.kind === "none")
158
+ continue;
159
+ // An id a `number` cannot carry is skipped, not thrown: see above. The
160
+ // input is attacker-written, so failing the whole text here would let one
161
+ // crafted mention take down the recording read that calls this. It is
162
+ // reported here instead, which is what keeps the skip from being silent.
163
+ if (mentioned.kind === "unrepresentable") {
164
+ if (!seenUnnameable.has(mentioned.id)) {
165
+ seenUnnameable.add(mentioned.id);
166
+ unnameable.push(mentioned.id);
167
+ }
168
+ continue;
169
+ }
170
+ if (seen.has(mentioned.id))
171
+ continue;
172
+ seen.add(mentioned.id);
173
+ ids.push(mentioned.id);
174
+ }
175
+ return { ids, unnameable };
176
+ }
177
+ /**
178
+ * A GlobalID URL, split into authority and path.
179
+ *
180
+ * The scheme is matched case-insensitively, as a URL parser matches it. The
181
+ * class is deliberately permissive — what may appear in an authority is decided
182
+ * by {@link isParsableHost}, which models the rules `net/url` actually applies
183
+ * rather than approximating them with a character class.
184
+ */
185
+ const GID_URL = /^gid:\/\/([^/?#]*)(\/[^?#]*)?(?:[?#][\s\S]*)?$/i;
186
+ /**
187
+ * `url.Parse` refuses a URL containing any control byte, anywhere in the string
188
+ * — `stringContainsCTLByte`, checked before parsing — where a character class
189
+ * on the authority alone would let one through in the query.
190
+ */
191
+ // oxlint-disable-next-line no-control-regex
192
+ const CONTROL_BYTE = /[\u0000-\u001f\u007f]/;
193
+ /**
194
+ * Whether an authority is one `url.Parse` would accept.
195
+ *
196
+ * Measured against `net/url` rather than inferred, because the earlier version
197
+ * got its central rule backwards: it refused every percent-escape in a host on
198
+ * the strength of a claim that `net/url` does. It does not.
199
+ * `unescape(…, encodeHost)` refuses an escape only when the first hex digit is
200
+ * below 8 — an escape of an ASCII byte — and exempts `%25`, so `b%C3%A9c3` is a
201
+ * host Go accepts and decodes.
202
+ *
203
+ * What it models: userinfo split at the last `@` and held to `validUserinfo`; a
204
+ * host that may not be empty; the optional-port rule; the escape rule above;
205
+ * and — since the bracket sweep — the literal itself, through
206
+ * {@link isIPv6Literal}. This paragraph used to say the opposite, that a
207
+ * bracketed authority was accepted on the port rule alone; it stayed behind
208
+ * when the check landed, which is the more dangerous half of a stale comment:
209
+ * a reader deciding what to trust here would have read a wider contract than
210
+ * the code keeps.
211
+ *
212
+ * An unbracketed authority with SEVERAL colons — `bc3:80:90` — is accepted,
213
+ * and deliberately: `url.Parse` accepts it too (measured; host `bc3:80:90`),
214
+ * because `validOptionalPort` only ever sees the text after the LAST colon.
215
+ * `[::1]:80:90` is refused on both sides by the same rule read from the last
216
+ * bracket.
217
+ */
218
+ function isParsableHost(authority) {
219
+ // Userinfo comes off first, as `url.Parse` splits it at the LAST "@" before
220
+ // looking at the host at all — otherwise the colon in "user:pass@bc3" reads
221
+ // as a port and the gid is refused, where Go accepts it.
222
+ const at = authority.lastIndexOf("@");
223
+ if (at >= 0 && !isValidUserinfo(authority.slice(0, at)))
224
+ return false;
225
+ const host = at < 0 ? authority : authority.slice(at + 1);
226
+ // An empty host, which is what "gid://user@/Person/1" parses to. `url.Parse`
227
+ // ACCEPTS that — the refusal is `u.Host == ""` in Go's own PersonIDFromSGID,
228
+ // not in net/url, and the distinction matters because a reader re-deriving
229
+ // this from net/url alone would delete the check. Without it the read side
230
+ // INVENTS a mention Go does not report.
231
+ if (host === "")
232
+ return false;
233
+ if (host.startsWith("[")) {
234
+ // `parseHost` takes the LAST "]", checks what follows it as a port, and
235
+ // then validates the literal itself. Accepting any bracketed authority on
236
+ // the port rule alone — which this did — let "[a]", "[]", "[1.2.3.4]" and
237
+ // "[ab]]" all name a person Go refuses: 29 of 48 bracket shapes, every one
238
+ // in the invent direction.
239
+ const close = host.lastIndexOf("]");
240
+ if (close < 0 || !isOptionalPort(host.slice(close + 1)))
241
+ return false;
242
+ return isIPv6Literal(host.slice(1, close));
243
+ }
244
+ if (FORBIDDEN_IN_HOST.test(host))
245
+ return false;
246
+ const colon = host.lastIndexOf(":");
247
+ if (colon >= 0 && !isOptionalPort(host.slice(colon)))
248
+ return false;
249
+ return hasParsableHostEscapes(colon < 0 ? host : host.slice(0, colon));
250
+ }
251
+ /**
252
+ * Whether a bracketed authority holds something `netip.ParseAddr` accepts.
253
+ *
254
+ * IPv6 only: a bare dotted quad in brackets is refused, and the embedded-IPv4
255
+ * tail is the only place dots may appear. An RFC 6874 zone is spelled `%25`
256
+ * followed by a non-empty zone, and the address before it must still parse —
257
+ * `[a%25eth0]` is refused, as is `[fe80::1%25]` and a zone carrying a malformed
258
+ * escape. Derived by sweeping 48 bracket shapes through Go rather than from the
259
+ * RFC, because the interesting rules are `netip`'s rather than the grammar's.
260
+ */
261
+ function isIPv6Literal(inner) {
262
+ const zoneAt = inner.indexOf("%25");
263
+ if (zoneAt >= 0) {
264
+ if (!isParsableZone(inner.slice(zoneAt + 3)))
265
+ return false;
266
+ return isIPv6Address(inner.slice(0, zoneAt));
267
+ }
268
+ return isIPv6Address(inner);
269
+ }
270
+ /**
271
+ * Whether an RFC 6874 zone is one `unescape(…, encodeZone)` accepts.
272
+ *
273
+ * `hasValidEscapes` is the wrong rule here, and that was the defect: `unescape`
274
+ * requires different things per mode, and the zone gets the other one. Swept
275
+ * from Go rather than read off `shouldEscape`: every printable ASCII planted in
276
+ * a zone, and all 256 two-digit escapes.
277
+ *
278
+ * Literals: everything is allowed except the nine below — the host's forbidden
279
+ * set plus `@`. Non-ASCII is allowed (é, CJK, emoji and NBSP all parse); DEL is
280
+ * refused, but as a control byte it never reaches here.
281
+ *
282
+ * Escapes: `%XX` must be two hex digits AND decode into the 85-byte set below —
283
+ * `%20` and `%25` among them, and nothing above U+007F, so 171 of the 256 are
284
+ * refused.
285
+ */
286
+ const FORBIDDEN_IN_ZONE = /[ @[\\^`{|}]/;
287
+ const ESCAPABLE_IN_ZONE = /^[A-Za-z0-9 !"$%&'()*+,\-.:;<=>[\]_~]$/;
288
+ function isParsableZone(zone) {
289
+ if (zone === "")
290
+ return false;
291
+ for (let i = 0; i < zone.length;) {
292
+ if (zone.charCodeAt(i) === 0x25 /* % */) {
293
+ const high = hexDigit(zone.charCodeAt(i + 1), true);
294
+ const low = hexDigit(zone.charCodeAt(i + 2), true);
295
+ if (high < 0 || low < 0)
296
+ return false;
297
+ if (!ESCAPABLE_IN_ZONE.test(String.fromCharCode(high * 16 + low)))
298
+ return false;
299
+ i += 3;
300
+ continue;
301
+ }
302
+ if (FORBIDDEN_IN_ZONE.test(zone[i]))
303
+ return false;
304
+ i++;
305
+ }
306
+ return true;
307
+ }
308
+ function isIPv6Address(text) {
309
+ const compressAt = text.indexOf("::");
310
+ const compressed = compressAt >= 0;
311
+ if (compressed && text.indexOf("::", compressAt + 1) >= 0)
312
+ return false;
313
+ const head = compressed ? text.slice(0, compressAt) : text;
314
+ const tail = compressed ? text.slice(compressAt + 2) : "";
315
+ const parts = [
316
+ ...(head === "" ? [] : head.split(":")),
317
+ ...(tail === "" ? [] : tail.split(":")),
318
+ ];
319
+ // A dotted quad is legal only at the END OF THE ADDRESS. The flattened list
320
+ // is head ++ tail, so its last element is the address's last only when the
321
+ // address does not END with the compression: with a trailing "::" the last
322
+ // element of `parts` sits in FRONT of it, and "1.2.3.4::" is not an address.
323
+ const quadIndex = !compressed || tail !== "" ? parts.length - 1 : -1;
324
+ let groups = 0;
325
+ for (let i = 0; i < parts.length; i++) {
326
+ const part = parts[i];
327
+ if (i === quadIndex && part.includes(".")) {
328
+ if (!isIPv4Address(part))
329
+ return false;
330
+ groups += 2;
331
+ continue;
332
+ }
333
+ if (!/^[0-9A-Fa-f]{1,4}$/.test(part))
334
+ return false;
335
+ groups += 1;
336
+ }
337
+ // "::" must stand for at least one group; without it every group is spelled.
338
+ return compressed ? groups < 8 : groups === 8;
339
+ }
340
+ function isIPv4Address(text) {
341
+ const octets = text.split(".");
342
+ if (octets.length !== 4)
343
+ return false;
344
+ // No leading zeros: `netip.ParseAddr` refuses "192.168.001.1".
345
+ return octets.every((octet) => /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/.test(octet));
346
+ }
347
+ /**
348
+ * The characters `url.Parse` refuses in an unbracketed host, swept rather than
349
+ * derived: every printable ASCII was planted mid-host and the ones Go rejected
350
+ * are exactly these. `/`, `?` and `#` are already outside the authority group,
351
+ * and `:` is left to the port rule. Note what is NOT here — `"`, `<`, `>` and
352
+ * `]` are all accepted by Go, and an earlier version of this file refused them.
353
+ */
354
+ const FORBIDDEN_IN_HOST = /[ [\\^`{|}]/;
355
+ /** Whether every `%` in a string introduces two hex digits, as `unescape` requires. */
356
+ function hasValidEscapes(text) {
357
+ for (let i = text.indexOf("%"); i >= 0; i = text.indexOf("%", i + 1)) {
358
+ if (hexDigit(text.charCodeAt(i + 1), true) < 0)
359
+ return false;
360
+ if (hexDigit(text.charCodeAt(i + 2), true) < 0)
361
+ return false;
362
+ }
363
+ return true;
364
+ }
365
+ /**
366
+ * The escapes `unescape(…, encodeHost)` accepts: two hex digits, with the first
367
+ * at least 8 — an escape of a non-ASCII byte — and `%25` exempt.
368
+ */
369
+ function hasParsableHostEscapes(host) {
370
+ for (let i = host.indexOf("%"); i >= 0; i = host.indexOf("%", i + 1)) {
371
+ const high = hexDigit(host.charCodeAt(i + 1), true);
372
+ const low = hexDigit(host.charCodeAt(i + 2), true);
373
+ if (high < 0 || low < 0)
374
+ return false;
375
+ if (high < 8 && !(high === 2 && low === 5))
376
+ return false;
377
+ }
378
+ return true;
379
+ }
380
+ /** Go's `validUserinfo`: the unreserved set, the sub-delims, and ":~%@". */
381
+ function isValidUserinfo(userinfo) {
382
+ if (!/^[A-Za-z0-9\-._:~!$&'()*+,;=%@]*$/.test(userinfo))
383
+ return false;
384
+ // An escape in userinfo is unescaped too, and a malformed one is an error.
385
+ for (let i = userinfo.indexOf("%"); i >= 0; i = userinfo.indexOf("%", i + 1)) {
386
+ if (hexDigit(userinfo.charCodeAt(i + 1), true) < 0)
387
+ return false;
388
+ if (hexDigit(userinfo.charCodeAt(i + 2), true) < 0)
389
+ return false;
390
+ }
391
+ return true;
392
+ }
393
+ /**
394
+ * The path with its percent-escapes resolved, or `undefined` when one of them
395
+ * is malformed.
396
+ *
397
+ * `url.Parse` decodes the path and refuses an escape that is not two hex
398
+ * digits, so `gid://bc3/Pe%72son/1` names a Person in Go and `…/1%zz` is a
399
+ * parse error. Decoding here rather than refusing every escape is what keeps
400
+ * the two agreeing — and it is the opposite of the rule for the HOST, where Go
401
+ * refuses escapes outright. The escapes resolve to BYTES which are then read as
402
+ * UTF-8, as Go reads them, so a multi-byte character spelled in escapes comes
403
+ * back as itself rather than as its bytes.
404
+ */
405
+ function percentDecodePath(path) {
406
+ if (!path.includes("%"))
407
+ return path;
408
+ const encoder = new TextEncoder();
409
+ const bytes = [];
410
+ for (let i = 0; i < path.length;) {
411
+ const code = path.charCodeAt(i);
412
+ if (code !== 0x25 /* % */) {
413
+ // Encode the whole unescaped RUN at once. Per code unit, a surrogate pair
414
+ // becomes two lone halves — six replacement bytes instead of the
415
+ // character's four — which nothing downstream reads today and would be a
416
+ // real defect the moment something did.
417
+ let run = i;
418
+ while (run < path.length && path.charCodeAt(run) !== 0x25)
419
+ run++;
420
+ for (const byte of encoder.encode(path.slice(i, run)))
421
+ bytes.push(byte);
422
+ i = run;
423
+ continue;
424
+ }
425
+ const high = hexDigit(path.charCodeAt(i + 1), true);
426
+ const low = hexDigit(path.charCodeAt(i + 2), true);
427
+ if (high < 0 || low < 0)
428
+ return undefined;
429
+ bytes.push(high * 16 + low);
430
+ i += 3;
431
+ }
432
+ return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
433
+ }
434
+ function isOptionalPort(port) {
435
+ return port === "" || (port.startsWith(":") && /^[0-9]*$/.test(port.slice(1)));
436
+ }
437
+ /**
438
+ * The Person id an `attachable_sgid` names, or `undefined` when the sgid does
439
+ * not decode, or names something other than a Person (a file attachment's sgid
440
+ * names an `ActiveStorage::Blob`).
441
+ *
442
+ * This reads the id out of the sgid's payload; it does not verify the sgid's
443
+ * signature, which only BC3 can. It is a read-side helper: never use its answer
444
+ * to decide that a write may skip the authoritative people read (see the trust
445
+ * boundary in the module comment).
446
+ */
447
+ export function personIdFromSGID(sgid) {
448
+ const parsed = parsePersonSGID(sgid);
449
+ return parsed.kind === "person" ? parsed.id : undefined;
450
+ }
451
+ const NOT_A_MENTION = { kind: "none" };
452
+ function parsePersonSGID(sgid) {
453
+ const gid = globalIDFromSGID(sgid);
454
+ if (gid === undefined)
455
+ return NOT_A_MENTION;
456
+ // A GlobalID is `gid://<app>/<Model>/<id>`, optionally with a query — BC3
457
+ // mints `gid://bc3/Person/1049715915?expires_in=`. The query and fragment are
458
+ // stripped the way a URL parser strips them, and the path must then be
459
+ // exactly `/<Model>/<id>`: no more, no less.
460
+ // `Parse` cuts the fragment off first and only then refuses control bytes, so
461
+ // a control byte in the fragment is fine and one in the query is not — and
462
+ // the fragment IS unescaped afterwards while the query is left raw, so a
463
+ // malformed escape is an error there and not there. "Anywhere in the string"
464
+ // was wrong in both halves.
465
+ const hash = gid.indexOf("#");
466
+ const beforeFragment = hash < 0 ? gid : gid.slice(0, hash);
467
+ if (CONTROL_BYTE.test(beforeFragment))
468
+ return NOT_A_MENTION;
469
+ if (hash >= 0 && !hasValidEscapes(gid.slice(hash + 1)))
470
+ return NOT_A_MENTION;
471
+ const parsed = GID_URL.exec(gid);
472
+ if (parsed === null)
473
+ return NOT_A_MENTION;
474
+ if (!isParsableHost(parsed[1] ?? ""))
475
+ return NOT_A_MENTION;
476
+ const path = percentDecodePath(parsed[2] ?? "");
477
+ if (path === undefined)
478
+ return NOT_A_MENTION;
479
+ const separator = path.indexOf("/", 1);
480
+ if (separator < 0)
481
+ return NOT_A_MENTION;
482
+ const model = path.slice(1, separator);
483
+ const rawId = path.slice(separator + 1);
484
+ if (model !== "Person" || rawId === "" || !/^[0-9]+$/.test(rawId))
485
+ return NOT_A_MENTION;
486
+ // Go parses the walked digits with `strconv.ParseInt(rawID, 10, 64)` and then
487
+ // refuses `id <= 0` (`go/pkg/basecamp/mentions.go:257-259`), so `scanPersonId`
488
+ // stands in for ParseInt here — NOT as the rule (the digit walk above is the
489
+ // rule, and it is the half that differs from the other two sites), but as the
490
+ // same int64 bound Go applies after it. `Number(rawId)` was the old spelling
491
+ // and gave every one of these answers too; it just could not tell a magnitude
492
+ // past int64, which is not a mention in Go either, from a real person past
493
+ // 2^53, which is.
494
+ const scan = scanPersonId(rawId);
495
+ if (scan.kind !== "value" || scan.value <= 0n)
496
+ return NOT_A_MENTION;
497
+ const id = personIdNumber(scan.value);
498
+ // A BC3 person id is well inside the safe range; one that is not cannot be
499
+ // reported as a number without rounding it into a different person.
500
+ if (id === undefined)
501
+ return { kind: "unrepresentable", id: scan.value.toString() };
502
+ return { kind: "person", id };
503
+ }
504
+ /**
505
+ * The `sgid` attribute of every `<bc-attachment>` in the text, in document
506
+ * order.
507
+ *
508
+ * It walks the markup as a stream of tags rather than pattern-matching for one
509
+ * tag name, so a `<bc-attachment>` inside an HTML comment or inside another
510
+ * element's quoted attribute is not an element; and it tokenizes each tag's
511
+ * attributes rather than pattern-matching them, so a `>` inside a quoted value
512
+ * does not end the tag, an `sgid=` inside another attribute's value is not an
513
+ * attribute, either quote style works, attribute order and case are free, the
514
+ * first `sgid` attribute wins as in HTML, and the character references an sgid
515
+ * can carry are decoded — a narrower set than a browser's, for the reason
516
+ * {@link NAMED_ENTITIES} gives.
517
+ */
518
+ function bcAttachmentSGIDs(text) {
519
+ const sgids = [];
520
+ let pos = 0;
521
+ while (pos < text.length) {
522
+ const open = text.indexOf("<", pos);
523
+ if (open < 0)
524
+ break;
525
+ pos = open + 1;
526
+ if (text.startsWith("!--", pos)) {
527
+ const stop = text.indexOf("-->", pos);
528
+ if (stop < 0)
529
+ return sgids; // an unterminated comment swallows the rest
530
+ pos = stop + 3;
531
+ continue;
532
+ }
533
+ const lead = text.charCodeAt(pos);
534
+ if (lead === CH_BANG || lead === CH_QUESTION || lead === CH_SLASH) {
535
+ const stop = text.indexOf(">", pos);
536
+ if (stop < 0)
537
+ return sgids;
538
+ pos = stop + 1;
539
+ continue;
540
+ }
541
+ let nameEnd = pos;
542
+ while (nameEnd < text.length && isTagNameChar(text.charCodeAt(nameEnd)))
543
+ nameEnd++;
544
+ if (nameEnd === pos)
545
+ continue; // a bare "<" in text
546
+ const attrs = parseAttributes(text, nameEnd);
547
+ if (!attrs.closed)
548
+ return sgids; // an unterminated tag: nothing after it is markup
549
+ if (equalsFolded(text.slice(pos, nameEnd), "bc-attachment") && attrs.sgid !== "") {
550
+ sgids.push(attrs.sgid);
551
+ }
552
+ pos = attrs.end;
553
+ }
554
+ return sgids;
555
+ }
556
+ const CH_TAB = 0x09;
557
+ const CH_NEWLINE = 0x0a;
558
+ const CH_FORM_FEED = 0x0c;
559
+ const CH_RETURN = 0x0d;
560
+ const CH_SPACE = 0x20;
561
+ const CH_BANG = 0x21;
562
+ const CH_DOUBLE_QUOTE = 0x22;
563
+ const CH_SINGLE_QUOTE = 0x27;
564
+ const CH_SLASH = 0x2f;
565
+ const CH_LT = 0x3c;
566
+ const CH_EQUALS = 0x3d;
567
+ const CH_GT = 0x3e;
568
+ const CH_QUESTION = 0x3f;
569
+ function isSpace(code) {
570
+ return (code === CH_SPACE ||
571
+ code === CH_TAB ||
572
+ code === CH_NEWLINE ||
573
+ code === CH_RETURN ||
574
+ code === CH_FORM_FEED);
575
+ }
576
+ /**
577
+ * What may follow `<` in a tag name. The whole name is consumed, punctuation
578
+ * included, so `<bc-attachment:preview` or `<bc-attachment_x` is its own name
579
+ * and never compares equal to `bc-attachment`.
580
+ */
581
+ function isTagNameChar(code) {
582
+ return (!isSpace(code) &&
583
+ code !== CH_SLASH &&
584
+ code !== CH_GT &&
585
+ code !== CH_LT &&
586
+ code !== CH_EQUALS &&
587
+ code !== CH_DOUBLE_QUOTE &&
588
+ code !== CH_SINGLE_QUOTE);
589
+ }
590
+ function isTagNameEnd(code) {
591
+ return isSpace(code) || code === CH_SLASH || code === CH_GT;
592
+ }
593
+ /**
594
+ * Walks the attributes of an opening tag from `start` (just after the tag name)
595
+ * to its closing `>`. The first `sgid` attribute wins, present-but-empty
596
+ * included, as HTML resolves a repeated attribute.
597
+ */
598
+ function parseAttributes(text, start) {
599
+ let pos = start;
600
+ let sgid = "";
601
+ let sgidSeen = false;
602
+ while (pos < text.length) {
603
+ while (pos < text.length && (isSpace(text.charCodeAt(pos)) || text.charCodeAt(pos) === CH_SLASH)) {
604
+ pos++;
605
+ }
606
+ if (pos >= text.length)
607
+ return { sgid, end: pos, closed: false };
608
+ if (text.charCodeAt(pos) === CH_GT)
609
+ return { sgid, end: pos + 1, closed: true };
610
+ const nameStart = pos;
611
+ while (pos < text.length) {
612
+ const code = text.charCodeAt(pos);
613
+ if (isSpace(code) || code === CH_EQUALS || code === CH_GT || code === CH_SLASH)
614
+ break;
615
+ pos++;
616
+ }
617
+ const name = text.slice(nameStart, pos);
618
+ while (pos < text.length && isSpace(text.charCodeAt(pos)))
619
+ pos++;
620
+ let value = "";
621
+ if (pos < text.length && text.charCodeAt(pos) === CH_EQUALS) {
622
+ pos++;
623
+ while (pos < text.length && isSpace(text.charCodeAt(pos)))
624
+ pos++;
625
+ const quote = pos < text.length ? text.charCodeAt(pos) : -1;
626
+ if (quote === CH_DOUBLE_QUOTE || quote === CH_SINGLE_QUOTE) {
627
+ pos++;
628
+ const closing = text.indexOf(String.fromCharCode(quote), pos);
629
+ if (closing < 0)
630
+ return { sgid, end: text.length, closed: false };
631
+ value = text.slice(pos, closing);
632
+ pos = closing + 1;
633
+ }
634
+ else {
635
+ const valueStart = pos;
636
+ while (pos < text.length) {
637
+ const code = text.charCodeAt(pos);
638
+ if (isSpace(code) || code === CH_GT)
639
+ break;
640
+ pos++;
641
+ }
642
+ value = text.slice(valueStart, pos);
643
+ }
644
+ }
645
+ if (name === "") {
646
+ // A stray "=" or quote where a name should be: step over it.
647
+ pos++;
648
+ continue;
649
+ }
650
+ if (!sgidSeen && equalsFolded(name, "sgid")) {
651
+ sgidSeen = true;
652
+ sgid = unescapeEntities(value);
653
+ }
654
+ }
655
+ return { sgid, end: pos, closed: false };
656
+ }
657
+ /**
658
+ * The named character references that can change what an sgid decodes to.
659
+ *
660
+ * NOT the full HTML5 table Go's `html.UnescapeString` carries. That table has
661
+ * 2231 names; this is the 23 of them whose expansion consists entirely of
662
+ * characters that can matter here — base64 alphabet, padding, or Go whitespace
663
+ * (which {@link trimGoSpace} strips at either end). The subset was extracted
664
+ * from Go's own `html/entity.go` by filtering on that property, not chosen by
665
+ * eye.
666
+ *
667
+ * Every other name expands to something no base64 payload can contain, so Go
668
+ * expands it and refuses the sgid while this leaves it literal and refuses the
669
+ * sgid: different text, same verdict. The keys carry their terminating `;`
670
+ * exactly as Go's table does, which is also why a name outside this subset can
671
+ * never be shadowed by one inside it — `sol;` is not a prefix of `solb;`. The
672
+ * one name Go lists twice, with and without the semicolon, is listed twice
673
+ * here.
674
+ *
675
+ * Two rows come from Go's SECOND table, `entity2`, whose values are rune PAIRS:
676
+ * `&fjlig;` is "fj" — two base64 letters — and `&ThickSpace;` is two spaces. A
677
+ * subset derived by reading only the single-rune table misses them, which is
678
+ * why the enumeration asks `html.UnescapeString` for every value rather than
679
+ * parsing the table's literals.
680
+ */
681
+ const NAMED_ENTITIES = new Map([
682
+ ["AMP;", "&"],
683
+ ["AMP", "&"],
684
+ ["GT;", ">"],
685
+ ["GT", ">"],
686
+ ["LT;", "<"],
687
+ ["LT", "<"],
688
+ ["MediumSpace;", "\u205f"],
689
+ ["NewLine;", "\n"],
690
+ ["NonBreakingSpace;", "\u00a0"],
691
+ ["QUOT;", '"'],
692
+ ["QUOT", '"'],
693
+ ["Tab;", "\t"],
694
+ ["ThickSpace;", "\u205f\u200a"],
695
+ ["ThinSpace;", "\u2009"],
696
+ ["UnderBar;", "_"],
697
+ ["VeryThinSpace;", "\u200a"],
698
+ ["amp;", "&"],
699
+ ["amp", "&"],
700
+ ["apos;", "'"],
701
+ ["bne;", "=\u20e5"],
702
+ ["emsp13;", "\u2004"],
703
+ ["emsp14;", "\u2005"],
704
+ ["emsp;", "\u2003"],
705
+ ["ensp;", "\u2002"],
706
+ ["equals;", "="],
707
+ ["fjlig;", "fj"],
708
+ ["gt;", ">"],
709
+ ["gt", ">"],
710
+ ["hairsp;", "\u200a"],
711
+ ["lowbar;", "_"],
712
+ ["lt;", "<"],
713
+ ["lt", "<"],
714
+ ["nbsp;", "\u00a0"],
715
+ ["nbsp", "\u00a0"],
716
+ ["numsp;", "\u2007"],
717
+ ["plus;", "+"],
718
+ ["puncsp;", "\u2008"],
719
+ ["quot;", '"'],
720
+ ["quot", '"'],
721
+ ["sol;", "/"],
722
+ ["thinsp;", "\u2009"],
723
+ ]);
724
+ /**
725
+ * The names in {@link NAMED_ENTITIES}, exposed so a test can hold the table to
726
+ * the shape {@link unescapeEntity}'s search bound assumes: `[A-Za-z0-9]+` with
727
+ * an optional `;`. A future row that broke that — a name with a hyphen, say —
728
+ * would silently stop being findable rather than fail to compile.
729
+ */
730
+ export function namedEntityNames() {
731
+ return [...NAMED_ENTITIES.keys()];
732
+ }
733
+ /** What a character reference's name may be made of, in Go's table and here. */
734
+ function isEntityNameChar(code) {
735
+ return ((code >= 0x30 && code <= 0x39) ||
736
+ (code >= 0x41 && code <= 0x5a) ||
737
+ (code >= 0x61 && code <= 0x7a));
738
+ }
739
+ /** The longest key in {@link NAMED_ENTITIES}, so the scan knows where to start. */
740
+ const LONGEST_ENTITY_NAME = Math.max(...[...NAMED_ENTITIES.keys()].map((name) => name.length));
741
+ /**
742
+ * Windows-1252 for U+0080-U+009F, which is what Go substitutes for a numeric
743
+ * reference in that range rather than emitting the C1 control.
744
+ */
745
+ const WINDOWS_1252 = [
746
+ 0x20ac, 0x0081, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030, 0x0160,
747
+ 0x2039, 0x0152, 0x008d, 0x017d, 0x008f, 0x0090, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022,
748
+ 0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x009d, 0x017e, 0x0178,
749
+ ];
750
+ const REPLACEMENT = "\ufffd";
751
+ /**
752
+ * Decodes the character reference at `start` (where the text has `&`), Go's
753
+ * way, returning the replacement text and the index just past what it consumed.
754
+ *
755
+ * The boundary rules are read off `html.UnescapeString` by measurement rather
756
+ * than from its source, because they are not guessable: a decimal reference
757
+ * needs two digits when no `;` follows it and one when it does (`&#6B` is
758
+ * literal, `&#65B` is `"AB"`, `&#6;B` is a tab then `B`); a hex reference needs
759
+ * only one digit either way; `&#x;` with no digits at all is U+FFFD while
760
+ * `&#;` is literal; and a trailing `;` is consumed when present but never
761
+ * required except in those two cases.
762
+ */
763
+ function unescapeEntity(text, start) {
764
+ const literal = () => ["&", start + 1];
765
+ if (text.charCodeAt(start + 1) !== 0x23 /* # */) {
766
+ // Bound the search by the name characters actually PRESENT before trying
767
+ // any of them. Every key in the table is `[A-Za-z0-9]+` with an optional
768
+ // `;` (pinned by a test), so nothing longer than that run — plus the
769
+ // semicolon, if one follows it — can match.
770
+ //
771
+ // Searching every length down from the longest key instead is correct but
772
+ // costs a slice and a lookup per length for each `&`, whatever follows it:
773
+ // a run of ampersands is the worst case, it is reachable from any attribute
774
+ // an author writes, and it is invisible in an optimised test run. Measured
775
+ // on 400k ampersands: 245ms before, and the shape stays linear either way,
776
+ // so wall clock alone would not have shown it — the constant is the defect.
777
+ let end = start + 1;
778
+ while (end < text.length && isEntityNameChar(text.charCodeAt(end)))
779
+ end++;
780
+ const nameLength = end - (start + 1);
781
+ if (nameLength === 0)
782
+ return literal();
783
+ const semicolon = end < text.length && text.charCodeAt(end) === 0x3b ? 1 : 0;
784
+ for (let length = Math.min(nameLength + semicolon, LONGEST_ENTITY_NAME); length > 0; length--) {
785
+ const replacement = NAMED_ENTITIES.get(text.slice(start + 1, start + 1 + length));
786
+ if (replacement !== undefined)
787
+ return [replacement, start + 1 + length];
788
+ }
789
+ return literal();
790
+ }
791
+ let pos = start + 2;
792
+ const lead = text.charCodeAt(pos);
793
+ const hex = lead === 0x78 || lead === 0x58; /* x X */
794
+ if (hex)
795
+ pos++;
796
+ const digitsStart = pos;
797
+ // Go accumulates into a `rune`, which is an int32 and WRAPS on overflow, and
798
+ // range-checks the wrapped result — so "&#4294967361;" is "A", 65 having gone
799
+ // once round. `| 0` is that same wrap. There is deliberately no cap on the
800
+ // digit count: capping one truncates a zero-padded reference, and
801
+ // "&#00000000065;" is an ordinary way to write "A", not a hostile input.
802
+ let value = 0;
803
+ while (pos < text.length) {
804
+ const digit = hexDigit(text.charCodeAt(pos), hex);
805
+ if (digit < 0)
806
+ break;
807
+ value = (value * (hex ? 16 : 10) + digit) | 0;
808
+ pos++;
809
+ }
810
+ const digits = pos - digitsStart;
811
+ const terminated = pos < text.length && text.charCodeAt(pos) === 0x3b; /* ; */
812
+ if (digits === 0) {
813
+ // `&#x;` is the one zero-digit form Go accepts, as the value zero.
814
+ if (!hex || !terminated)
815
+ return literal();
816
+ }
817
+ else if (!hex && digits === 1 && !terminated) {
818
+ return literal();
819
+ }
820
+ if (terminated)
821
+ pos++;
822
+ if (value >= 0x80 && value <= 0x9f)
823
+ return [String.fromCodePoint(WINDOWS_1252[value - 0x80]), pos];
824
+ // `<= 0`, not `=== 0`: the int32 wrap above can land on a NEGATIVE value, and
825
+ // Go's `utf8.EncodeRune` reads the rune as a `uint32` and writes U+FFFD for
826
+ // anything past MaxRune. Guarding only zero let a negative through to
827
+ // `String.fromCodePoint`, which throws a RangeError — an untyped throw out of
828
+ // a public read helper, on content a server can serve.
829
+ if (value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) {
830
+ return [REPLACEMENT, pos];
831
+ }
832
+ return [String.fromCodePoint(value), pos];
833
+ }
834
+ function hexDigit(code, hex) {
835
+ if (code >= 0x30 && code <= 0x39)
836
+ return code - 0x30;
837
+ if (!hex)
838
+ return -1;
839
+ if (code >= 0x61 && code <= 0x66)
840
+ return code - 0x61 + 10;
841
+ if (code >= 0x41 && code <= 0x46)
842
+ return code - 0x41 + 10;
843
+ return -1;
844
+ }
845
+ /**
846
+ * Decodes the character references an HTML attribute value may carry.
847
+ *
848
+ * Exported for the test that pins the scanner against Go's. It is NOT part of
849
+ * the package surface — `src/index.ts` does not re-export it — and it is
850
+ * exported at all because a test asserting only "this names nobody" is
851
+ * satisfied by any failure that also names nobody, including a decoder that
852
+ * does nothing. Pinning the decoded string is what makes the row fail under the
853
+ * regression it is written to catch.
854
+ */
855
+ export function unescapeEntities(value) {
856
+ if (!value.includes("&"))
857
+ return value;
858
+ let out = "";
859
+ let pos = 0;
860
+ for (;;) {
861
+ const amp = value.indexOf("&", pos);
862
+ if (amp < 0)
863
+ return out + value.slice(pos);
864
+ out += value.slice(pos, amp);
865
+ const [replacement, next] = unescapeEntity(value, amp);
866
+ out += replacement;
867
+ pos = next;
868
+ }
869
+ }
870
+ // =============================================================================
871
+ // SignedGlobalID envelopes
872
+ // =============================================================================
873
+ /**
874
+ * The SignedGlobalID purpose BC3 mints attachable sgids with
875
+ * (doc/api/sections/rich_text.md: `attachable_sgid`). Pinned by the purpose
876
+ * cases in the mention tests, so a rename upstream breaks a test here rather
877
+ * than silently turning every mention invisible.
878
+ */
879
+ const SGID_PURPOSE_ATTACHABLE = "attachable";
880
+ /**
881
+ * Bounds the decoded sgid payload. A Person sgid's payload is under 200 bytes;
882
+ * the cap keeps a hostile one from costing more than its own size to reject.
883
+ */
884
+ const MAX_SGID_PAYLOAD_BYTES = 4096;
885
+ /**
886
+ * The same bound on the base64 form (4/3 of the payload, plus padding), checked
887
+ * before anything is allocated.
888
+ */
889
+ const MAX_SGID_ENCODED_BYTES = Math.floor(MAX_SGID_PAYLOAD_BYTES / 3) * 4 + 4;
890
+ /**
891
+ * Go's `unicode.IsSpace` set, which is what `strings.TrimSpace` trims.
892
+ *
893
+ * Neither a subset nor a superset of what `String.prototype.trim` strips, so
894
+ * neither is usable as-is: JS omits U+0085 (NEL), which Go trims, and JS strips
895
+ * U+FEFF (BOM), which Go does not — because the BOM is a format character, not
896
+ * White_Space. Left to `trim()`, an sgid padded with NEL loses its mention here
897
+ * and keeps it in Go, and one padded with a BOM gains a mention here that Go
898
+ * refuses. Both directions matter: one vanishes a real mention, the other
899
+ * invents one.
900
+ */
901
+ const GO_SPACE = "\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
902
+ const GO_TRIM = new RegExp(`^[${GO_SPACE}]+|[${GO_SPACE}]+$`, "gu");
903
+ function trimGoSpace(value) {
904
+ return value.replace(GO_TRIM, "");
905
+ }
906
+ /**
907
+ * The global id string an sgid's envelope carries.
908
+ *
909
+ * A signed sgid is `<payload>--<digest>`, and `-` is a base64url character, so
910
+ * the payload itself may contain `--`. The separator is therefore the LAST one,
911
+ * as Rails' own verifier reads it; the whole value is tried as a bare payload
912
+ * when that fails, which is what an unsigned envelope — one that happens to
913
+ * contain `--` included — needs.
914
+ */
915
+ function globalIDFromSGID(sgid) {
916
+ const value = trimGoSpace(sgid);
917
+ const separator = value.lastIndexOf("--");
918
+ if (separator > 0) {
919
+ const gid = envelopeGID(value.slice(0, separator));
920
+ if (gid !== undefined)
921
+ return gid;
922
+ }
923
+ return envelopeGID(value);
924
+ }
925
+ /** Decodes one base64 payload and returns the gid its envelope carries. */
926
+ function envelopeGID(payload) {
927
+ // The bound is applied to the encoded form first, so an oversized sgid costs
928
+ // nothing to refuse — no normalization, no decode buffer.
929
+ if (payload === "" || payload.length > MAX_SGID_ENCODED_BYTES)
930
+ return undefined;
931
+ const raw = decodeBase64(payload);
932
+ if (raw === undefined || raw.length === 0 || raw.length > MAX_SGID_PAYLOAD_BYTES)
933
+ return undefined;
934
+ let envelope;
935
+ if (raw.length >= 2 && raw[0] === 0x04 && raw[1] === 0x08) {
936
+ envelope = unmarshalRuby(raw.subarray(2));
937
+ if (envelope === MARSHAL_FAILED)
938
+ return undefined;
939
+ }
940
+ else if (raw[0] === 0x7b /* { */) {
941
+ try {
942
+ // Non-fatal: Go's JSON decoder substitutes U+FFFD for invalid UTF-8
943
+ // inside a string rather than refusing the document, so an envelope with
944
+ // bad bytes in a field other than the gid decodes on both sides.
945
+ envelope = JSON.parse(new TextDecoder("utf-8").decode(raw));
946
+ }
947
+ catch {
948
+ return undefined;
949
+ }
950
+ }
951
+ else {
952
+ return undefined;
953
+ }
954
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope))
955
+ return undefined;
956
+ const top = envelope;
957
+ // A SignedGlobalID is bound to a purpose, and only an "attachable" one may be
958
+ // placed in rich text: BC3 refuses any other, so a Person sgid minted for
959
+ // bookmarking or reading is not a mention however valid its gid. Both layouts
960
+ // carry the purpose; an envelope without one is not a Rails envelope.
961
+ //
962
+ // Current layout: {"_rails" => {"data" => gid, "pur" => purpose}}.
963
+ const rails = top["_rails"];
964
+ if (typeof rails === "object" && rails !== null && !Array.isArray(rails)) {
965
+ const inner = rails;
966
+ if (inner["pur"] !== SGID_PURPOSE_ATTACHABLE)
967
+ return undefined;
968
+ const gid = inner["data"];
969
+ return typeof gid === "string" && gid !== "" ? gid : undefined;
970
+ }
971
+ // Older layout: {"gid" => gid, "purpose" => …, "expires_at" => …}.
972
+ if (top["purpose"] !== SGID_PURPOSE_ATTACHABLE)
973
+ return undefined;
974
+ const gid = top["gid"];
975
+ return typeof gid === "string" && gid !== "" ? gid : undefined;
976
+ }
977
+ /**
978
+ * Decodes a base64 payload in either alphabet.
979
+ *
980
+ * Rails' MessageVerifier emits base64url (current) or standard base64, and both
981
+ * decode through the standard alphabet once the two symbols are mapped;
982
+ * stripping the padding lets a truncated-but-valid payload through, and
983
+ * `atob` accepts the unpadded form.
984
+ */
985
+ function decodeBase64(payload) {
986
+ // The order of these two is Go's, and it is observable. Go right-trims `=`
987
+ // off the string as written and only then decodes, and its decoder ignores CR
988
+ // and LF as it goes — so "MA==" decodes and "MA==\n" does NOT, the trim
989
+ // having stopped at the newline and left an `=` the raw alphabet refuses.
990
+ // Stripping the newlines first would quietly decode that one.
991
+ //
992
+ // The alphabet check afterwards is the other half of the parity: `atob`
993
+ // ignores every ASCII whitespace character, where Go's decoder ignores only
994
+ // CR and LF, so a space or a tab inside an sgid would decode here and be
995
+ // refused there.
996
+ const normalized = payload
997
+ .replace(/-/g, "+")
998
+ .replace(/_/g, "/")
999
+ .replace(/=+$/, "")
1000
+ .replace(/[\r\n]/g, "");
1001
+ if (!/^[A-Za-z0-9+/]*$/.test(normalized))
1002
+ return undefined;
1003
+ let binary;
1004
+ try {
1005
+ binary = atob(normalized);
1006
+ }
1007
+ catch {
1008
+ return undefined;
1009
+ }
1010
+ const bytes = new Uint8Array(binary.length);
1011
+ for (let i = 0; i < binary.length; i++)
1012
+ bytes[i] = binary.charCodeAt(i) & 0xff;
1013
+ return bytes;
1014
+ }
1015
+ // =============================================================================
1016
+ // Ruby Marshal (the subset a SignedGlobalID payload uses)
1017
+ // =============================================================================
1018
+ /**
1019
+ * The sentinel a failed Marshal decode returns.
1020
+ *
1021
+ * A unique object rather than `undefined` or a thrown error: `nil` is a value
1022
+ * the format can legitimately carry, so "failed" needs a token no payload can
1023
+ * produce, and the caller then treats the sgid as undecodable rather than
1024
+ * guessing.
1025
+ */
1026
+ const MARSHAL_FAILED = Symbol("marshal failed");
1027
+ /** Bounds nesting in a payload; an envelope is two deep. */
1028
+ const RUBY_MARSHAL_MAX_DEPTH = 32;
1029
+ /**
1030
+ * Decodes the subset of Ruby's Marshal 4.8 format a SignedGlobalID payload uses
1031
+ * — nil, booleans, fixnums, strings (with their encoding ivars), symbols and
1032
+ * symbol links, arrays and hashes — into plain JS values. Anything else returns
1033
+ * {@link MARSHAL_FAILED}.
1034
+ */
1035
+ function unmarshalRuby(data) {
1036
+ const reader = new RubyMarshalReader(data);
1037
+ const value = reader.value(0);
1038
+ if (value === MARSHAL_FAILED)
1039
+ return MARSHAL_FAILED;
1040
+ // A Marshal dump is exactly one value; bytes after it are corruption.
1041
+ if (reader.pos !== data.length)
1042
+ return MARSHAL_FAILED;
1043
+ return value;
1044
+ }
1045
+ class RubyMarshalReader {
1046
+ #data;
1047
+ #symbols = [];
1048
+ #decoder = new TextDecoder("utf-8", { fatal: false });
1049
+ pos = 0;
1050
+ constructor(data) {
1051
+ this.#data = data;
1052
+ }
1053
+ #byte() {
1054
+ if (this.pos >= this.#data.length)
1055
+ return MARSHAL_FAILED;
1056
+ return this.#data[this.pos++];
1057
+ }
1058
+ /**
1059
+ * Takes the next `n` bytes. The bound is checked against what remains, never
1060
+ * by adding `n` to the position: every length reaches here through
1061
+ * {@link #count}, which already rejected anything past the remaining bytes.
1062
+ */
1063
+ #bytes(n) {
1064
+ if (n < 0 || n > this.#data.length - this.pos)
1065
+ return MARSHAL_FAILED;
1066
+ const slice = this.#data.subarray(this.pos, this.pos + n);
1067
+ this.pos += n;
1068
+ return slice;
1069
+ }
1070
+ /**
1071
+ * Reads Marshal's packed integer: 0 is 0; 1..4 and -1..-4 are a byte count
1072
+ * for a little-endian value; anything else is the value itself offset by 5.
1073
+ *
1074
+ * Every width the format allows is at most four bytes, so both branches stay
1075
+ * inside the double's exact-integer range and no bitwise 32-bit truncation is
1076
+ * involved.
1077
+ */
1078
+ #int() {
1079
+ const lead = this.#byte();
1080
+ if (lead === MARSHAL_FAILED)
1081
+ return MARSHAL_FAILED;
1082
+ // The lead byte is a signed int8; widen it into its signed meaning.
1083
+ const c = lead > 127 ? lead - 256 : lead;
1084
+ if (c === 0)
1085
+ return 0;
1086
+ if (c > 4)
1087
+ return c - 5;
1088
+ if (c < -4)
1089
+ return c + 5;
1090
+ const width = c > 0 ? c : -c;
1091
+ const raw = this.#bytes(width);
1092
+ if (raw === MARSHAL_FAILED)
1093
+ return MARSHAL_FAILED;
1094
+ let unsigned = 0;
1095
+ for (let i = 0; i < raw.length; i++)
1096
+ unsigned += raw[i] * 2 ** (8 * i);
1097
+ // A negative packed integer is the same little-endian run sign-extended
1098
+ // with 0xff bytes, which is exactly `unsigned - 256**width`.
1099
+ return c > 0 ? unsigned : unsigned - 2 ** (8 * width);
1100
+ }
1101
+ /**
1102
+ * Reads a length or count — string and symbol bytes, array elements, hash
1103
+ * pairs, ivar pairs — and rejects one that cannot be honest: negative, or
1104
+ * more than the bytes left (every element takes at least one byte).
1105
+ * Allocation follows what actually decodes, so a hostile count costs its own
1106
+ * bytes to refuse, never the capacity it claims.
1107
+ */
1108
+ #count() {
1109
+ const n = this.#int();
1110
+ if (n === MARSHAL_FAILED)
1111
+ return MARSHAL_FAILED;
1112
+ if (n < 0 || n > this.#data.length - this.pos)
1113
+ return MARSHAL_FAILED;
1114
+ return n;
1115
+ }
1116
+ #string() {
1117
+ const n = this.#count();
1118
+ if (n === MARSHAL_FAILED)
1119
+ return MARSHAL_FAILED;
1120
+ const raw = this.#bytes(n);
1121
+ if (raw === MARSHAL_FAILED)
1122
+ return MARSHAL_FAILED;
1123
+ return this.#decoder.decode(raw);
1124
+ }
1125
+ value(depth) {
1126
+ if (depth > RUBY_MARSHAL_MAX_DEPTH)
1127
+ return MARSHAL_FAILED;
1128
+ const type = this.#byte();
1129
+ if (type === MARSHAL_FAILED)
1130
+ return MARSHAL_FAILED;
1131
+ switch (type) {
1132
+ case 0x30: // '0'
1133
+ return null;
1134
+ case 0x54: // 'T'
1135
+ return true;
1136
+ case 0x46: // 'F'
1137
+ return false;
1138
+ case 0x69: // 'i'
1139
+ return this.#int();
1140
+ case 0x22: // '"'
1141
+ return this.#string();
1142
+ case 0x3a: {
1143
+ // ':' — a symbol, which later occurrences link back to by index.
1144
+ const symbol = this.#string();
1145
+ if (symbol === MARSHAL_FAILED)
1146
+ return MARSHAL_FAILED;
1147
+ this.#symbols.push(symbol);
1148
+ return symbol;
1149
+ }
1150
+ case 0x3b: {
1151
+ // ';' — a symbol link.
1152
+ const index = this.#int();
1153
+ if (index === MARSHAL_FAILED)
1154
+ return MARSHAL_FAILED;
1155
+ if (index < 0 || index >= this.#symbols.length)
1156
+ return MARSHAL_FAILED;
1157
+ return this.#symbols[index];
1158
+ }
1159
+ case 0x49: {
1160
+ // 'I' — an object followed by its instance variables (a String's encoding).
1161
+ const inner = this.value(depth + 1);
1162
+ if (inner === MARSHAL_FAILED)
1163
+ return MARSHAL_FAILED;
1164
+ const n = this.#count();
1165
+ if (n === MARSHAL_FAILED)
1166
+ return MARSHAL_FAILED;
1167
+ for (let i = 0; i < n; i++) {
1168
+ if (this.value(depth + 1) === MARSHAL_FAILED)
1169
+ return MARSHAL_FAILED; // ivar name
1170
+ if (this.value(depth + 1) === MARSHAL_FAILED)
1171
+ return MARSHAL_FAILED; // ivar value
1172
+ }
1173
+ return inner;
1174
+ }
1175
+ case 0x5b: {
1176
+ // '[' — an array.
1177
+ const n = this.#count();
1178
+ if (n === MARSHAL_FAILED)
1179
+ return MARSHAL_FAILED;
1180
+ const out = [];
1181
+ for (let i = 0; i < n; i++) {
1182
+ const element = this.value(depth + 1);
1183
+ if (element === MARSHAL_FAILED)
1184
+ return MARSHAL_FAILED;
1185
+ out.push(element);
1186
+ }
1187
+ return out;
1188
+ }
1189
+ case 0x7b: {
1190
+ // '{' — a hash.
1191
+ const n = this.#count();
1192
+ if (n === MARSHAL_FAILED)
1193
+ return MARSHAL_FAILED;
1194
+ const out = Object.create(null);
1195
+ for (let i = 0; i < n; i++) {
1196
+ const key = this.value(depth + 1);
1197
+ if (key === MARSHAL_FAILED)
1198
+ return MARSHAL_FAILED;
1199
+ const value = this.value(depth + 1);
1200
+ if (value === MARSHAL_FAILED)
1201
+ return MARSHAL_FAILED;
1202
+ if (typeof key !== "string")
1203
+ return MARSHAL_FAILED;
1204
+ out[key] = value;
1205
+ }
1206
+ return out;
1207
+ }
1208
+ default:
1209
+ return MARSHAL_FAILED;
1210
+ }
1211
+ }
1212
+ }
1213
+ // =============================================================================
1214
+ // Writing mentions
1215
+ // =============================================================================
1216
+ /**
1217
+ * The `<bc-attachment>` that mentions a person, rendered from their
1218
+ * `attachable_sgid` — the write-side form in doc/api/sections/rich_text.md,
1219
+ * which BC3 expands into the avatar figure on read.
1220
+ *
1221
+ * It throws when the person carries no `attachable_sgid`, which is the case for
1222
+ * a Person projection that came from somewhere other than a people read (a
1223
+ * webhook payload, say), and when the sgid does not name the person it is
1224
+ * given. That is all it can check: it cannot verify the signature, so the
1225
+ * Person must come from the API — a `people.get`, a recording's creator or
1226
+ * assignees — not be assembled from an sgid found in content.
1227
+ *
1228
+ * @throws {BasecampError} `usage` when the person cannot be mentioned.
1229
+ */
1230
+ export function mentionMarkup(person) {
1231
+ if (person === null || typeof person !== "object") {
1232
+ throw Errors.usage("cannot mention a person that is not a person object");
1233
+ }
1234
+ const sgid = person.attachable_sgid;
1235
+ // A STRING. `attachable_sgid` is typed as one, but the value arrives off a
1236
+ // response, and a truthy non-string (a number, an object) passed the presence
1237
+ // check, survived the markup regex — `test` stringifies — and reached the
1238
+ // parser, whose first character read threw a raw TypeError out of a helper
1239
+ // documented to raise a usage error. Go cannot reach this state: its field
1240
+ // decodes as a string or the read fails.
1241
+ if (sgid !== undefined && sgid !== null && typeof sgid !== "string") {
1242
+ throw Errors.usage(`person ${person.id} has an attachable_sgid that is not a string`, "read the person through people.get to obtain one");
1243
+ }
1244
+ if (!sgid) {
1245
+ throw Errors.usage(`person ${person.id} has no attachable_sgid to mention`, "read the person through people.get to obtain one");
1246
+ }
1247
+ if (/["'<>&]/.test(sgid)) {
1248
+ throw Errors.usage(`person ${person.id} has a malformed attachable_sgid`);
1249
+ }
1250
+ // The tag mentions whoever the sgid names. Refuse to write one that names
1251
+ // someone else — or a file — under this person's id, and refuse one that
1252
+ // names nobody at all. The two halves are separate on purpose: comparing only
1253
+ // "names a different person" would pass an undecodable sgid for a person
1254
+ // projection carrying no id, since neither side would be a number.
1255
+ const named = personIdFromSGID(sgid);
1256
+ if (named === undefined || named !== personIdValue(person.id)) {
1257
+ throw Errors.usage(`person ${person.id}'s attachable_sgid does not name that person`, "read the person through people.get to obtain their own");
1258
+ }
1259
+ return `<bc-attachment sgid="${sgid}"></bc-attachment>`;
1260
+ }
1261
+ /**
1262
+ * The id Go reads off a person the API served, for comparing against the one an
1263
+ * sgid names.
1264
+ *
1265
+ * `Person.Id` is the single `FlexibleInt64` in the generated model, so a person
1266
+ * id that arrives as a JSON STRING of digits *is* that number in Go — with or
1267
+ * without `personable_type`, which is the gap this exists to close. The SDK's
1268
+ * own `normalizePersonIds` rewrites a string id only when `personable_type` is
1269
+ * present, so a body without it reaches here still carrying `"1049715914"`, and
1270
+ * comparing that to the number the sgid names refused a person Go mentions.
1271
+ *
1272
+ * Measured against the reference, not inferred: `"1049715914"` and `"+7"` are
1273
+ * their numbers (`strconv.ParseInt` takes the sign), `"basecamp"`, `" 7"` and
1274
+ * `"7.0"` are all 0 — Go's non-numeric sentinel, which is what this returns for
1275
+ * them because it is what Go reads. No sgid can match it: `PersonIDFromSGID`
1276
+ * refuses a non-positive id, so every sentinel is a refusal by way of a zero
1277
+ * that nothing equals. `"9223372036854775808"` is the one string that fails Go's decode
1278
+ * outright and takes the people read with it; nothing at this layer can fail a
1279
+ * read that already succeeded, so it reads as unreadable and the mention is
1280
+ * refused — a refusal either way, by a different route.
1281
+ *
1282
+ * The grammar itself is {@link scanPersonId}, shared with the pre-decode
1283
+ * normalizer in `base.ts` so the two cannot answer differently about the same
1284
+ * string. Reading `[+-]?\d+` off a regex first and only then checking the
1285
+ * magnitude was the remaining defect here: it puts the whole-string test BEFORE
1286
+ * the magnitude check, where Go's scan interleaves them. `"18446744073709551616x"`
1287
+ * therefore read `0` — the SYSTEM ACTOR — where Go, which overflows u64 before
1288
+ * it ever reaches the `x`, raises a range error and fails the read. Its
1289
+ * neighbour `"18446744073709551615x"` really is a syntax error and really does
1290
+ * read `0`. One digit apart, opposite verdicts; only a scan in Go's order gets
1291
+ * both.
1292
+ *
1293
+ * Exported for `tests/services/mentions.test.ts`, which pins the corpus against
1294
+ * this function rather than against `mentionMarkup`: the two refusals are
1295
+ * INDISTINGUISHABLE through the public path, since `0` and `undefined` both
1296
+ * refuse the mention, so a test that could only see the verdict could not see
1297
+ * the defect above. It is not re-exported from `index.ts` and is not public API.
1298
+ */
1299
+ export function personIdValue(value) {
1300
+ if (typeof value === "number")
1301
+ return value;
1302
+ if (typeof value !== "string")
1303
+ return undefined;
1304
+ const scan = scanPersonId(value);
1305
+ // Go's sentinel zero for a syntax refusal; the number for a value this
1306
+ // platform can hold; "unreadable" for a range refusal AND for a value past
1307
+ // 2^53, which is the judgment call argued at personIdNumber.
1308
+ if (scan.kind === "syntax")
1309
+ return 0;
1310
+ if (scan.kind === "range")
1311
+ return undefined;
1312
+ return personIdNumber(scan.value);
1313
+ }
1314
+ /**
1315
+ * Whether `text` equals an ASCII `target` under Go's `strings.EqualFold`.
1316
+ *
1317
+ * `toLowerCase()` is not that rule, and the gap is a dropped mention rather
1318
+ * than an invented one: `EqualFold("ſgid", "sgid")` is TRUE — U+017F LATIN
1319
+ * SMALL LETTER LONG S shares an orbit with `s` — so BC3 markup written with one
1320
+ * IS a mention the reference reports, and lowercasing left it out.
1321
+ *
1322
+ * The table is measured, not recalled: sweeping every rune in Unicode against
1323
+ * the 26 ASCII letters and `-` gives exactly two that fold onto an ASCII letter
1324
+ * from outside ASCII, U+017F onto `s` and U+212A KELVIN SIGN onto `k`. Both are
1325
+ * single UTF-16 units, so a unit-by-unit comparison is exact here, and the
1326
+ * target being ASCII is what keeps this from needing full case folding.
1327
+ *
1328
+ * This is the HTML half of the port's folding. The `gid` scheme is compared
1329
+ * ASCII-only, because `url.Parse` has already excluded everything else by the
1330
+ * time a scheme is read — one file, two rules, and carrying either answer to
1331
+ * the other site is wrong.
1332
+ *
1333
+ * Two of the three call sites cannot observe the difference: neither
1334
+ * `bc-attachment` nor `<p`/`<div` contains an `s` or a `k`, so `toLowerCase()`
1335
+ * is exactly equivalent there and a revert of either reads as behaviour-neutral
1336
+ * in the tests. They use this anyway, so the rule lives in one place and the
1337
+ * next literal to gain an `s` does not have to be noticed.
1338
+ *
1339
+ * And it is deliberately no WIDER than the orbit table. "Same when upper-cased"
1340
+ * would accept `sgıd` — `"ı".toUpperCase()` is "I" — which `EqualFold` refuses;
1341
+ * that pair is pinned, because a too-wide fold invents mentions exactly where a
1342
+ * too-narrow one drops them.
1343
+ */
1344
+ const FOLDS_ONTO_ASCII = new Map([
1345
+ [0x017f, 0x73 /* s */],
1346
+ [0x212a, 0x6b /* k */],
1347
+ ]);
1348
+ function equalsFolded(text, target) {
1349
+ if (text.length !== target.length)
1350
+ return false;
1351
+ for (let i = 0; i < target.length; i++) {
1352
+ const want = target.charCodeAt(i);
1353
+ const code = text.charCodeAt(i);
1354
+ if (code === want)
1355
+ continue;
1356
+ // ASCII case, then the two orbits that reach ASCII from outside it.
1357
+ if (code >= 0x41 && code <= 0x5a && code + 0x20 === want)
1358
+ continue;
1359
+ if (FOLDS_ONTO_ASCII.get(code) === want)
1360
+ continue;
1361
+ return false;
1362
+ }
1363
+ return true;
1364
+ }
1365
+ /**
1366
+ * The index just past the opening `<p …>` or `<div …>` tag a rich text starts
1367
+ * with, or `-1` when it starts with anything else, so mentions can be placed
1368
+ * inside the first block rather than as a bare prefix in front of it. The tag's
1369
+ * attributes are scanned quote-aware: a `>` inside an attribute value does not
1370
+ * end it.
1371
+ */
1372
+ function leadingBlockEnd(content) {
1373
+ let i = 0;
1374
+ while (i < content.length && isSpace(content.charCodeAt(i)))
1375
+ i++;
1376
+ for (const name of ["<p", "<div"]) {
1377
+ if (content.length < i + name.length)
1378
+ continue;
1379
+ if (!equalsFolded(content.slice(i, i + name.length), name))
1380
+ continue;
1381
+ const after = i + name.length;
1382
+ if (after < content.length && !isTagNameEnd(content.charCodeAt(after)))
1383
+ continue;
1384
+ const attrs = parseAttributes(content, after);
1385
+ return attrs.closed ? attrs.end : -1;
1386
+ }
1387
+ return -1;
1388
+ }
1389
+ /**
1390
+ * Content that mentions each of the given people, for posting as a comment or a
1391
+ * Campfire line.
1392
+ *
1393
+ * A person whose exact `attachable_sgid` the content already carries is left
1394
+ * alone, so passing the same person twice — or a person the author already
1395
+ * mentioned with that sgid — never duplicates the mention; the rest are added
1396
+ * at the start of the content, inside its first `<p>` or `<div>` when it opens
1397
+ * with one, so they render on the first line rather than as a block of their
1398
+ * own.
1399
+ *
1400
+ * This is the write side, and it deduplicates on the sgid string alone, never
1401
+ * on the person id an existing tag's sgid decodes to: that id is unsigned, and
1402
+ * a forged or stale tag naming the right person must not stand in for the real
1403
+ * mention (see the trust boundary in the module comment). Every person needs
1404
+ * their own `attachable_sgid`, and it must be one the API returned: this helper
1405
+ * can check that an sgid is well-formed and names the person, not that it is
1406
+ * authentic (see {@link mentionMarkup}). The account-bound
1407
+ * `CommentsService.expandMentions` resolves ids to people first and is the
1408
+ * entry point that carries that guarantee.
1409
+ *
1410
+ * @throws {BasecampError} `usage` when a person cannot be mentioned.
1411
+ */
1412
+ export function withMentions(content, people) {
1413
+ const present = new Set(bcAttachmentSGIDs(content));
1414
+ const tags = [];
1415
+ for (const person of people) {
1416
+ // Every person is validated, duplicates included: a caller passing an
1417
+ // unmentionable person twice hears about it either way.
1418
+ const tag = mentionMarkup(person);
1419
+ const sgid = person.attachable_sgid;
1420
+ if (present.has(sgid))
1421
+ continue;
1422
+ present.add(sgid);
1423
+ tags.push(tag);
1424
+ }
1425
+ if (tags.length === 0)
1426
+ return content;
1427
+ const prefix = tags.join(" ") + " ";
1428
+ const end = leadingBlockEnd(content);
1429
+ return end >= 0 ? content.slice(0, end) + prefix + content.slice(end) : prefix + content;
1430
+ }
1431
+ //# sourceMappingURL=mentions.js.map