@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.
- package/CHANGELOG.md +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- 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
|
+
}
|