@symbiosis-lab/moss-plugin-matters 1.4.2

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 (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Matters credential — the single owner of this folder's matters login.
3
+ *
4
+ * `auth.json` (per-folder plugin storage) is the source of truth. The two
5
+ * credentials a folder uses are both projections of it:
6
+ * - the `x-access-token` HTTP header for API calls (authHeaderToken)
7
+ * - the global `__access_token` cookie for matters.town webviews (prepareWebviewAuth)
8
+ *
9
+ * The global cookie is never a durable store — it is set just-in-time from
10
+ * auth.json before a webview, cleared before a fresh login, and read exactly
11
+ * once (capturing a fresh login back into auth.json).
12
+ */
13
+
14
+ import {
15
+ readPluginFile,
16
+ writePluginFile,
17
+ pluginFileExists,
18
+ getPluginCookie,
19
+ setPluginCookie,
20
+ clearPluginCookies,
21
+ } from "@symbiosis-lab/moss-api";
22
+
23
+ // ============================================================================
24
+ // Token storage (auth.json = SSOT)
25
+ // ============================================================================
26
+
27
+ const AUTH_FILE = "auth.json";
28
+
29
+ let cachedAccessToken: string | null = null;
30
+
31
+ /**
32
+ * Clear the cached access token
33
+ */
34
+ export function clearTokenCache(): void {
35
+ cachedAccessToken = null;
36
+ }
37
+
38
+ /**
39
+ * Load a USABLE access token from project-scoped plugin storage.
40
+ *
41
+ * Credential supply, not session evidence: returns null for expired or
42
+ * server-invalidated tokens so no caller (graphqlQuery, and critically the
43
+ * login flow's waitForToken poll, which reads storage FIRST) can pick up a
44
+ * dead credential. getSessionState reads the raw record instead.
45
+ */
46
+ export async function loadStoredToken(): Promise<string | null> {
47
+ const record = await loadAuthRecord();
48
+ if (!record || typeof record.accessToken !== "string") return null;
49
+ if (isRecordDead(record)) return null;
50
+ return record.accessToken;
51
+ }
52
+
53
+ /**
54
+ * Save access token to project-scoped plugin storage.
55
+ * This makes the token survive across sessions and scopes it to this project.
56
+ */
57
+ export async function saveStoredToken(token: string): Promise<void> {
58
+ const data = { accessToken: token, savedAt: new Date().toISOString() };
59
+ await writePluginFile(AUTH_FILE, JSON.stringify(data, null, 2));
60
+ console.log("💾 Access token saved to project storage");
61
+ }
62
+
63
+ /**
64
+ * Remove stored access token from project storage.
65
+ */
66
+ export async function clearStoredToken(): Promise<void> {
67
+ cachedAccessToken = null;
68
+ try {
69
+ await writePluginFile(AUTH_FILE, "{}");
70
+ } catch {
71
+ // Ignore write failures
72
+ }
73
+ }
74
+
75
+ // ============================================================================
76
+ // Session state
77
+ // ============================================================================
78
+
79
+ /**
80
+ * Decode the `exp` claim from a JWT, in milliseconds since epoch.
81
+ *
82
+ * No signature verification: we are reading our own stored credential to
83
+ * predict whether the server will accept it, not authenticating anyone.
84
+ * Returns null when the token is not a decodable JWT or has no numeric exp,
85
+ * in which case the caller must fall back to runtime detection.
86
+ */
87
+ export function decodeJwtExpiryMs(token: string): number | null {
88
+ const parts = token.split(".");
89
+ if (parts.length !== 3) return null;
90
+ try {
91
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
92
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
93
+ const claims = JSON.parse(atob(padded));
94
+ return typeof claims.exp === "number" ? claims.exp * 1000 : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export type SessionState = "valid" | "expired" | "none";
101
+
102
+ /** Tokens within this margin of expiry count as expired (clock skew). */
103
+ const EXPIRY_SKEW_MS = 60_000;
104
+
105
+ interface AuthRecord {
106
+ accessToken?: string;
107
+ savedAt?: string;
108
+ /** Stamped when the server rejected the token (TOKEN_INVALID). */
109
+ invalidatedAt?: string;
110
+ /** Stamped when the expired-session nudge was shown for this record. */
111
+ nudgedAt?: string;
112
+ }
113
+
114
+ async function loadAuthRecord(): Promise<AuthRecord | null> {
115
+ try {
116
+ const exists = await pluginFileExists(AUTH_FILE);
117
+ if (!exists) return null;
118
+ return JSON.parse(await readPluginFile(AUTH_FILE)) as AuthRecord;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** A record whose token the server would reject (past exp or server-stamped). */
125
+ function isRecordDead(record: AuthRecord): boolean {
126
+ if (record.invalidatedAt) return true;
127
+ if (typeof record.accessToken !== "string") return false;
128
+ const expMs = decodeJwtExpiryMs(record.accessToken);
129
+ return expMs !== null && expMs <= Date.now() + EXPIRY_SKEW_MS;
130
+ }
131
+
132
+ /**
133
+ * Honest session check: distinguishes a usable token ("valid"), a token the
134
+ * server will reject ("expired": past JWT exp or server-stamped invalid),
135
+ * and no token at all ("none"). Replaces the old presence-only check that
136
+ * logged AUTHENTICATED for a 30-days-dead token. Reads the RAW record:
137
+ * the expired token stays on disk as the "session expired" marker.
138
+ */
139
+ export async function getSessionState(): Promise<SessionState> {
140
+ const record = await loadAuthRecord();
141
+ if (!record || typeof record.accessToken !== "string") return "none";
142
+
143
+ if (record.invalidatedAt) {
144
+ console.log(`🔑 Token present but server-invalidated at ${record.invalidatedAt}`);
145
+ return "expired";
146
+ }
147
+
148
+ const expMs = decodeJwtExpiryMs(record.accessToken);
149
+ if (expMs === null) {
150
+ console.log("🔑 Token present (not a decodable JWT; assuming valid, runtime check will verify)");
151
+ return "valid";
152
+ }
153
+ if (expMs <= Date.now() + EXPIRY_SKEW_MS) {
154
+ console.log(`🔑 Token present but EXPIRED since ${new Date(expMs).toISOString()}`);
155
+ return "expired";
156
+ }
157
+ console.log(`🔑 Token present, expires ${new Date(expMs).toISOString()}`);
158
+ return "valid";
159
+ }
160
+
161
+ /**
162
+ * The server rejected the token (TOKEN_INVALID/UNAUTHENTICATED). Stamp the
163
+ * auth record so every later check is offline; keep the token so "expired
164
+ * session" stays distinguishable from "never logged in" (they route
165
+ * differently). A fresh login overwrites the whole record via
166
+ * saveStoredToken, clearing the stamp.
167
+ */
168
+ export async function markSessionInvalidated(): Promise<void> {
169
+ cachedAccessToken = null;
170
+ const record = await loadAuthRecord();
171
+ if (!record || typeof record.accessToken !== "string") {
172
+ // Nothing to invalidate. Stamping {invalidatedAt} alone would diverge
173
+ // the checks: getSessionState would say "none" while isRecordDead says
174
+ // dead. Clearing the cache above is still wanted.
175
+ return;
176
+ }
177
+ record.invalidatedAt = new Date().toISOString();
178
+ try {
179
+ await writePluginFile(AUTH_FILE, JSON.stringify(record, null, 2));
180
+ } catch {
181
+ // Best-effort: the runtime backstop fires again on the next request.
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Once-per-expiry-event throttle for the "session expired" toast, persisted
187
+ * in the auth record (NOT module state: the off-webview engine migration
188
+ * allows per-build contexts, under which module flags reset every build and
189
+ * sync_on_build would toast every build). Fresh login rewrites the record,
190
+ * clearing nudgedAt, so the next expiry event nudges again.
191
+ */
192
+ export async function shouldNudgeSessionExpired(): Promise<boolean> {
193
+ const record = await loadAuthRecord();
194
+ if (!record || typeof record.accessToken !== "string") return false;
195
+ if (record.nudgedAt) return false;
196
+ record.nudgedAt = new Date().toISOString();
197
+ try {
198
+ await writePluginFile(AUTH_FILE, JSON.stringify(record, null, 2));
199
+ } catch {
200
+ // Failing to persist means we may nudge again next build; harmless.
201
+ }
202
+ return true;
203
+ }
204
+
205
+ // ============================================================================
206
+ // Credential projections
207
+ // ============================================================================
208
+
209
+ /**
210
+ * Read a USABLE token for the `x-access-token` API header. Project storage
211
+ * only (no cookie) — the API never authenticates via the cookie. Returns null
212
+ * when there is no usable stored token (caller must trigger login).
213
+ */
214
+ export async function authHeaderToken(): Promise<string | null> {
215
+ if (cachedAccessToken !== null) {
216
+ return cachedAccessToken;
217
+ }
218
+ try {
219
+ const storedToken = await loadStoredToken();
220
+ if (storedToken) {
221
+ console.log("🔑 Using stored access token from project storage");
222
+ cachedAccessToken = storedToken;
223
+ return cachedAccessToken;
224
+ }
225
+ } catch {
226
+ // No usable stored token.
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Login-only: capture the freshly-set `__access_token` cookie into auth.json.
233
+ *
234
+ * Reads stored storage FIRST (so a still-valid token short-circuits), then the
235
+ * global WebKit cookie. Used only by the waitForToken login poll.
236
+ *
237
+ * @returns
238
+ * - `string` - the access token if found
239
+ * - `null` - no token found (but plugin context was available)
240
+ * - `undefined` - no plugin context (e.g., hook ended, window closed)
241
+ */
242
+ export async function captureLogin(): Promise<string | null | undefined> {
243
+ const fromStorage = await authHeaderToken();
244
+ if (fromStorage) return fromStorage;
245
+
246
+ try {
247
+ console.log("🍪 Checking cookies for access token (login flow)...");
248
+ const cookies = await getPluginCookie();
249
+
250
+ // null means "no plugin context" - signal caller to stop
251
+ if (cookies === null) {
252
+ console.log("⚠️ No plugin context - cannot get cookies");
253
+ return undefined;
254
+ }
255
+
256
+ const tokenCookie = cookies.find((c) => c.name === "__access_token");
257
+
258
+ if (tokenCookie) {
259
+ console.log(`Found __access_token cookie (length: ${tokenCookie.value?.length ?? 0})`);
260
+ // Dead-cookie filter: the shared WebKit store can still hold a token
261
+ // the server has revoked (matches the invalidatedAt-stamped record) or
262
+ // one whose exp already passed. Capturing it here would persist it via
263
+ // saveStoredToken (erasing the invalidatedAt stamp) and end the login
264
+ // poll with a dead credential, looping the user out of re-login. A
265
+ // rejected cookie behaves as "no token found" so the poll keeps
266
+ // waiting for the fresh one.
267
+ const value = tokenCookie.value;
268
+ const currentRecord = await loadAuthRecord();
269
+ if (isRecordDead({ accessToken: value })) {
270
+ console.warn("🍪 Ignoring expired __access_token cookie (stale login state)");
271
+ } else if (currentRecord?.invalidatedAt && currentRecord.accessToken === value) {
272
+ console.warn("🍪 Ignoring __access_token cookie matching the server-invalidated token");
273
+ } else {
274
+ cachedAccessToken = value;
275
+
276
+ // Immediately persist to project storage so future calls don't need cookies
277
+ try {
278
+ await saveStoredToken(value);
279
+ } catch (e) {
280
+ console.warn(`Failed to persist token to storage: ${e}`);
281
+ }
282
+ }
283
+ } else {
284
+ console.warn("__access_token cookie NOT found");
285
+ }
286
+
287
+ return cachedAccessToken;
288
+ } catch (error) {
289
+ console.error(`❌ Failed to capture login token: ${error}`);
290
+ return null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Project this folder's auth.json token into the global `__access_token` cookie
296
+ * so a matters.town webview authenticates as the BOUND account. The cookie is
297
+ * the webview's only credential (matters-web reads it via auto-send to
298
+ * server.matters.town — verified 2026-06-23). The manifest domain (.matters.town)
299
+ * + http_only are applied by the Rust write path, so the browser auto-sends it.
300
+ * Best-effort: a write failure must not abort the syndication flow. No-op when
301
+ * there is no usable token.
302
+ */
303
+ export async function prepareWebviewAuth(): Promise<void> {
304
+ const token = await authHeaderToken();
305
+ if (!token) {
306
+ console.warn("⚠️ prepareWebviewAuth: no usable token; webview will be unauthenticated");
307
+ return;
308
+ }
309
+ try {
310
+ await setPluginCookie([{ name: "__access_token", value: token }]);
311
+ } catch (e) {
312
+ console.warn(`⚠️ prepareWebviewAuth: failed to set matters cookie: ${e}`);
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Force-fresh login: clear THIS folder's stored token AND the matters-domain
318
+ * cookies before opening the login webview, so the login screen is genuine and
319
+ * only a freshly-logged-in token can be captured (getAccessToken/captureLogin
320
+ * read the stored token before the cookie). Cookie-clear failure is non-fatal.
321
+ */
322
+ export async function beginFreshLogin(): Promise<void> {
323
+ await clearStoredToken();
324
+ try {
325
+ await clearPluginCookies();
326
+ } catch (e) {
327
+ console.warn(`⚠️ Failed to clear matters cookies before login: ${e}`);
328
+ }
329
+ }
package/src/domain.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Domain configuration module for the Matters plugin
3
+ *
4
+ * Centralizes all domain-dependent URL construction and domain state.
5
+ * The domain defaults to "matters.town" but can be overridden via
6
+ * plugin config (config.json: { "domain": "matters.icu" }).
7
+ *
8
+ * Call initializeDomain() at the start of each hook (process, syndicate)
9
+ * to load the configured domain and update the API endpoint + manifest.
10
+ */
11
+
12
+ import { readPluginFile, writePluginFile, getPluginEnvVar } from "@symbiosis-lab/moss-api";
13
+ import { getConfig } from "./config";
14
+ import { apiConfig } from "./api";
15
+
16
+ // ============================================================================
17
+ // State
18
+ // ============================================================================
19
+
20
+ const DEFAULT_DOMAIN = "matters.town";
21
+ let currentDomain = DEFAULT_DOMAIN;
22
+
23
+ // ============================================================================
24
+ // Initialization
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Initialize domain configuration from plugin config.
29
+ *
30
+ * Must be called at the start of each hook (process, syndicate).
31
+ * Reads `domain` from config.json, updates:
32
+ * - Module-level currentDomain state
33
+ * - apiConfig.endpoint for GraphQL requests
34
+ * - manifest.json domain field (so Rust cookie filtering uses the correct domain)
35
+ *
36
+ * @returns The configured domain
37
+ */
38
+ export async function initializeDomain(): Promise<string> {
39
+ const config = await getConfig();
40
+ currentDomain = config.domain || DEFAULT_DOMAIN;
41
+
42
+ // Allow MOSS_MATTERS_DOMAIN env var to override config.domain — enables
43
+ // test-env switching (e.g. matters.icu) via moss-claude.sh without
44
+ // pre-seeding .moss/plugins/matters/config.json. Must happen before the
45
+ // endpoint is set so the whole init uses the env-specified domain.
46
+ const envDomain = await getPluginEnvVar("MOSS_MATTERS_DOMAIN");
47
+ if (envDomain && envDomain.length > 0) {
48
+ console.log(`📍 MOSS_MATTERS_DOMAIN override: ${envDomain} (was: ${currentDomain})`);
49
+ currentDomain = envDomain;
50
+ }
51
+
52
+ // Update API endpoint
53
+ apiConfig.endpoint = `https://server.${currentDomain}/graphql`;
54
+
55
+ // Update manifest.json domain if it differs (for Rust cookie filtering)
56
+ try {
57
+ const manifestContent = await readPluginFile("manifest.json");
58
+ const manifest = JSON.parse(manifestContent);
59
+
60
+ if (manifest.domain !== currentDomain) {
61
+ manifest.domain = currentDomain;
62
+ await writePluginFile("manifest.json", JSON.stringify(manifest, null, 2));
63
+ console.log(
64
+ `📍 Updated manifest domain to ${currentDomain}`
65
+ );
66
+ }
67
+ } catch {
68
+ // Manifest read/write failure is non-fatal
69
+ console.warn(
70
+ `⚠️ Could not update manifest domain to ${currentDomain}`
71
+ );
72
+ }
73
+
74
+ console.log(`📍 Matters domain: ${currentDomain}`);
75
+ return currentDomain;
76
+ }
77
+
78
+ /**
79
+ * Reset domain to default (for testing)
80
+ */
81
+ export function resetDomain(): void {
82
+ currentDomain = DEFAULT_DOMAIN;
83
+ apiConfig.endpoint = `https://server.${DEFAULT_DOMAIN}/graphql`;
84
+ }
85
+
86
+ // ============================================================================
87
+ // Getters
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Get the current configured domain
92
+ */
93
+ export function getDomain(): string {
94
+ return currentDomain;
95
+ }
96
+
97
+ // ============================================================================
98
+ // URL Builders
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Get the login page URL
103
+ */
104
+ export function loginUrl(): string {
105
+ return `https://${currentDomain}/login`;
106
+ }
107
+
108
+ /**
109
+ * Get the draft editor URL
110
+ */
111
+ export function draftUrl(draftId: string): string {
112
+ return `https://${currentDomain}/me/drafts/${draftId}`;
113
+ }
114
+
115
+ /**
116
+ * Get the published article URL
117
+ */
118
+ export function articleUrl(
119
+ userName: string,
120
+ slug: string,
121
+ shortHash: string
122
+ ): string {
123
+ return `https://${currentDomain}/@${userName}/${slug}-${shortHash}`;
124
+ }
125
+
126
+ // ============================================================================
127
+ // URL Matchers
128
+ // ============================================================================
129
+
130
+ /**
131
+ * Check if a URL belongs to the configured Matters domain
132
+ */
133
+ export function isMattersUrl(url: string): boolean {
134
+ return url.includes(currentDomain);
135
+ }
136
+
137
+ /**
138
+ * Extract the Matters shortHash from an article URL.
139
+ *
140
+ * Supports both URL forms Matters produces:
141
+ * - Canonical: https://matters.town/@user/<slug>-<shortHash> → <shortHash>
142
+ * - Short link: https://matters.town/a/<shortHash> → <shortHash>
143
+ *
144
+ * Accepts absolute or root-relative URLs. Returns null when no shortHash can be
145
+ * determined. Pure function: the fixed base host only lets `new URL` parse
146
+ * root-relative inputs and never affects the parsed pathname.
147
+ */
148
+ export function extractShortHash(url: string): string | null {
149
+ let path: string;
150
+ try {
151
+ path = new URL(url, "https://matters.town").pathname;
152
+ } catch {
153
+ return null;
154
+ }
155
+
156
+ const segments = path.split("/").filter(Boolean);
157
+ if (segments.length === 0) return null;
158
+
159
+ // Short-link form: /a/<shortHash> — the whole segment is the hash.
160
+ if (segments[0] === "a" && segments.length >= 2) {
161
+ return segments[1] || null;
162
+ }
163
+
164
+ // Canonical form: /@user/<slug>-<shortHash> — hash is after the final hyphen.
165
+ const last = segments[segments.length - 1];
166
+ const hyphen = last.lastIndexOf("-");
167
+ return hyphen === -1 ? null : last.substring(hyphen + 1) || null;
168
+ }
169
+
170
+ /**
171
+ * Check if a URL points to the current user's content on the configured domain
172
+ */
173
+ export function isInternalMattersLink(
174
+ url: string,
175
+ userName: string
176
+ ): boolean {
177
+ // Escape backslashes in domain before escaping dots for regex
178
+ const escapedDomain = currentDomain.replace(/\\/g, "\\\\").replace(/\./g, "\\.");
179
+ const pattern = new RegExp(
180
+ `^https?://${escapedDomain}/@${userName}/`
181
+ );
182
+ return pattern.test(url);
183
+ }