feedkeeper 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -5,9 +5,9 @@ import { config } from "../config.js";
5
5
  import { requireSession, requireAdmin } from "../auth/middleware.js";
6
6
  import { createUser, deleteUserAvatar, findUserByEmail, findUserById, getUserAvatar, listUsers, setUserAvatar, toPublicUser, updateDisplayName, dismissIntro, updateUserPassword, } from "../auth/users.js";
7
7
  import { AVATAR_MAX_BYTES, detectAvatarType } from "../auth/avatar.js";
8
- import { verifyPassword } from "../auth/password.js";
8
+ import { verifyPassword, verifyPasswordOrDummy } from "../auth/password.js";
9
9
  import { getUserCapabilities } from "../auth/capabilities.js";
10
- import { beginMfa, securityVersion } from "../auth/security.js";
10
+ import { beginMfa, revokeBrowserSessions, securityVersion } from "../auth/security.js";
11
11
  import { createSecurityRouter } from "../auth/securityRoutes.js";
12
12
  export const authRouter = Router();
13
13
  authRouter.use("/security", createSecurityRouter({ completeLogin: (req, res, userId) => {
@@ -31,7 +31,9 @@ authRouter.post("/login", loginLimiter, (req, res) => {
31
31
  return;
32
32
  }
33
33
  const user = findUserByEmail(parsed.data.email);
34
- if (!user || !verifyPassword(parsed.data.password, user.password_hash)) {
34
+ // Always check a password, so an unknown address answers as slowly as a wrong password.
35
+ const passwordMatches = verifyPasswordOrDummy(parsed.data.password, user?.password_hash);
36
+ if (!user || !passwordMatches) {
35
37
  res.status(401).json({ error: "invalid_credentials" });
36
38
  return;
37
39
  }
@@ -110,6 +112,9 @@ authRouter.post("/change-password", requireSession, loginLimiter, (req, res) =>
110
112
  return;
111
113
  }
112
114
  updateUserPassword(user.id, parsed.data.newPassword);
115
+ // Other browsers that were signed in with the old password are signed out; this one stays.
116
+ revokeBrowserSessions(user.id);
117
+ req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
113
118
  res.status(204).end();
114
119
  });
115
120
  const createUserSchema = z.object({
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import bcrypt from "bcryptjs";
2
3
  const SALT_ROUNDS = process.env.NODE_ENV === "test" ? 4 : 12;
3
4
  export function hashPassword(plain) {
@@ -6,4 +7,15 @@ export function hashPassword(plain) {
6
7
  export function verifyPassword(plain, hash) {
7
8
  return bcrypt.compareSync(plain, hash);
8
9
  }
10
+ // A hash nobody knows the password for. Checking it costs what checking a real one costs.
11
+ let unknownAccountHash;
12
+ /**
13
+ * Like verifyPassword for a sign-in, where the account may not exist: an unknown address takes as long as a known
14
+ * one, so the response time does not tell whether an account is registered.
15
+ */
16
+ export function verifyPasswordOrDummy(plain, hash) {
17
+ unknownAccountHash ??= bcrypt.hashSync(randomBytes(16).toString("hex"), SALT_ROUNDS);
18
+ const matches = bcrypt.compareSync(plain, hash ?? unknownAccountHash);
19
+ return hash !== undefined && matches;
20
+ }
9
21
  //# sourceMappingURL=password.js.map
@@ -96,6 +96,15 @@ export function newRecoveryCodes(userId) {
96
96
  db.prepare("INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)").run(userId, hash(code));
97
97
  return codes.map(code => code.match(/.{6}/g).join("-"));
98
98
  }
99
+ /**
100
+ * A new password ends every browser session and pending sign-in that knew the old one. Access tokens, paired
101
+ * devices and connected apps are the person's own and keep working; they can be removed one by one.
102
+ */
103
+ export function revokeBrowserSessions(userId) {
104
+ securityAccount(userId);
105
+ db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
106
+ db.prepare("DELETE FROM security_challenges WHERE user_id = ?").run(userId);
107
+ }
99
108
  /** Enabling MFA invalidates older password-only sessions, tokens, pairing codes and pending logins. */
100
109
  export function revokeSecuritySessions(userId) {
101
110
  db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
@@ -0,0 +1,12 @@
1
+ -- Migration 0032: publish dates that are not dates.
2
+ --
3
+ -- Entries whose feed gave a date that could not be read ("gestern", localised weekday names, an empty string) had
4
+ -- the raw text stored. Compared as text it sorted above every real date and never aged. Dates before 1991 and
5
+ -- dates more than a day ahead are placeholders (1970-01-01, year 9999). All of them become NULL, which makes the
6
+ -- app use the time it stored the article.
7
+ UPDATE items SET published_at = NULL
8
+ WHERE published_at IS NOT NULL AND julianday(published_at) IS NULL;
9
+
10
+ UPDATE items SET published_at = NULL
11
+ WHERE published_at IS NOT NULL
12
+ AND (julianday(published_at) < julianday('1991-01-01') OR julianday(published_at) > julianday('now', '+1 day'));
@@ -99,7 +99,7 @@ export function runCleanup(custom) {
99
99
  WHERE s.feed_id = i.feed_id
100
100
  AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = s.user_id)
101
101
  )
102
- AND COALESCE(i.published_at, i.created_at) <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' days')
102
+ AND COALESCE(julianday(i.published_at), julianday(i.created_at)) <= julianday('now', '-' || ? || ' days')
103
103
  )`).run(settings.retentionReadDays);
104
104
  deletedReadItems = res.changes;
105
105
  }
@@ -111,7 +111,7 @@ export function runCleanup(custom) {
111
111
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
112
112
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
113
  WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
114
- AND COALESCE(published_at, created_at) <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
114
+ AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
115
115
  deletedOldItems = res.changes;
116
116
  }
117
117
  // 3. Delete items exceeding retentionMaxItemsPerFeed (excluding bookmarked items and items with notes)
@@ -126,7 +126,7 @@ export function runCleanup(custom) {
126
126
  SELECT id FROM (
127
127
  SELECT id, ROW_NUMBER() OVER (
128
128
  PARTITION BY feed_id
129
- ORDER BY COALESCE(published_at, created_at) DESC, id DESC
129
+ ORDER BY COALESCE(julianday(published_at), julianday(created_at)) DESC, id DESC
130
130
  ) as rn
131
131
  FROM items
132
132
  )
@@ -0,0 +1,21 @@
1
+ // Nothing on the web is older than this, so an earlier date is a placeholder such as 1970-01-01.
2
+ const EARLIEST_PLAUSIBLE = Date.UTC(1991, 0, 1);
3
+ // Feeds may announce an article a little ahead of time or in another zone, but not by years.
4
+ const MAX_AHEAD_MS = 24 * 60 * 60 * 1000;
5
+ /**
6
+ * The publish date of a feed entry as an ISO timestamp, or null when the feed gives none we can trust: missing,
7
+ * not a date ("gestern", localised weekday names), a placeholder in the past or a date far in the future. Null makes
8
+ * the app use the time it stored the article, so the article lists, ages and is cleaned up like any other.
9
+ */
10
+ export function normalizePublishedAt(value, now = Date.now()) {
11
+ if (typeof value !== "string")
12
+ return null;
13
+ const text = value.trim();
14
+ if (!text)
15
+ return null;
16
+ const time = Date.parse(text);
17
+ if (!Number.isFinite(time) || time < EARLIEST_PLAUSIBLE || time > now + MAX_AHEAD_MS)
18
+ return null;
19
+ return new Date(time).toISOString();
20
+ }
21
+ //# sourceMappingURL=dates.js.map
@@ -9,6 +9,7 @@ import { findFeedById, listSubscriberIds, updateFeedAfterPoll, updateFeedIcon, u
9
9
  import { db } from "../db/index.js";
10
10
  import { configurePollHostCooldowns, PollQueue } from "./pollQueue.js";
11
11
  import { feedRequests, PollDeferredError, sourceHost } from "./requestGate.js";
12
+ import { normalizePublishedAt } from "./dates.js";
12
13
  configurePollHostCooldowns();
13
14
  function mediaAttribute(value, key) {
14
15
  if (typeof value !== "object" || value === null)
@@ -103,7 +104,7 @@ async function pollAttempt(feed, signal) {
103
104
  title: plainTitle(item.title), link: item.link,
104
105
  contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
105
106
  contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
106
- publishedAt: item.isoDate ?? item.pubDate, imageUrl: extractImageUrl(item),
107
+ publishedAt: normalizePublishedAt(item.isoDate ?? item.pubDate) ?? undefined, imageUrl: extractImageUrl(item),
107
108
  }));
108
109
  // No await in this transaction. Items, health/validators and job completion become visible together.
109
110
  const newItems = db.transaction(() => {
@@ -234,7 +234,7 @@ export function listItemsForUser(userId, opts = {}) {
234
234
  const limit = Math.min(opts.limit ?? 50, 200);
235
235
  const offset = Math.max(opts.offset ?? 0, 0);
236
236
  params.push(limit, offset);
237
- const orderBy = opts.sortByAdded ? "i.created_at DESC, i.id DESC" : opts.bookmarkedOnly ? "b.created_at DESC, i.id DESC" : "i.published_at DESC, i.created_at DESC, i.id DESC";
237
+ const orderBy = opts.sortByAdded ? "i.created_at DESC, i.id DESC" : opts.bookmarkedOnly ? "b.created_at DESC, i.id DESC" : "COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC, i.created_at DESC, i.id DESC";
238
238
  // Page IDs before loading HTML. User/feed and user/item keys make these joins unique.
239
239
  // Keep selection and loading in one statement so they share the same read snapshot.
240
240
  return db
@@ -583,8 +583,17 @@ export function formatFtsQuery(raw) {
583
583
  return "";
584
584
  return sanitized.join(" ");
585
585
  }
586
+ /**
587
+ * True when the query has letters or digits that the full-text index cannot be asked for as typed (Japanese,
588
+ * Cyrillic, Greek, ...). `formatFtsQuery` drops them, so a query such as "RSS 新聞" would silently search for "RSS"
589
+ * alone; such queries are matched word by word as substrings instead. Quoted phrases are passed through as typed.
590
+ */
591
+ export function needsSubstringSearch(raw) {
592
+ const outsideQuotes = raw.replace(/"[^"]*"/g, " ");
593
+ return /(?![a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF])[\p{L}\p{N}]/u.test(outsideQuotes);
594
+ }
586
595
  export function searchItemsForUser(userId, opts) {
587
- const fts = formatFtsQuery(opts.query);
596
+ const fts = needsSubstringSearch(opts.query) ? "" : formatFtsQuery(opts.query);
588
597
  const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
589
598
  const params = [];
590
599
  if (opts.feedId) {
@@ -610,14 +619,18 @@ export function searchItemsForUser(userId, opts) {
610
619
  params.push(fts, userId, opts.query);
611
620
  }
612
621
  else {
613
- conditions.push(`(
614
- INSTR(LOWER(COALESCE(i.title, '')), LOWER(?)) > 0
615
- OR INSTR(LOWER(COALESCE(i.content_snippet, '')), LOWER(?)) > 0
616
- OR INSTR(LOWER(COALESCE(i.content_html, '')), LOWER(?)) > 0
617
- OR INSTR(LOWER(COALESCE(i.full_content_html, '')), LOWER(?)) > 0
618
- OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
619
- )`);
620
- params.push(opts.query, opts.query, opts.query, opts.query, userId, opts.query);
622
+ // Every word has to occur somewhere in the article or in the person's note.
623
+ const words = opts.query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
624
+ for (const word of words) {
625
+ conditions.push(`(
626
+ INSTR(LOWER(COALESCE(i.title, '')), LOWER(?)) > 0
627
+ OR INSTR(LOWER(COALESCE(i.content_snippet, '')), LOWER(?)) > 0
628
+ OR INSTR(LOWER(COALESCE(i.content_html, '')), LOWER(?)) > 0
629
+ OR INSTR(LOWER(COALESCE(i.full_content_html, '')), LOWER(?)) > 0
630
+ OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
631
+ )`);
632
+ params.push(word, word, word, word, userId, word);
633
+ }
621
634
  }
622
635
  // Count total matches
623
636
  const totalRow = db.prepare(`
@@ -633,9 +646,9 @@ export function searchItemsForUser(userId, opts) {
633
646
  const total = totalRow?.count ?? 0;
634
647
  const limit = Math.min(opts.limit ?? 30, 100);
635
648
  const offset = Math.max(opts.offset ?? 0, 0);
636
- let orderBy = "i.published_at DESC, i.created_at DESC, i.id DESC";
649
+ let orderBy = "COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC, i.created_at DESC, i.id DESC";
637
650
  if (opts.sort !== "date" && fts) {
638
- orderBy = "COALESCE(fts.rank, 0) ASC, i.published_at DESC";
651
+ orderBy = "COALESCE(fts.rank, 0) ASC, COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC";
639
652
  }
640
653
  const querySql = `
641
654
  SELECT DISTINCT i.*,
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { config } from "../config.js";
7
7
  import { getUserCapabilities, hasUserCapability } from "../auth/capabilities.js";
8
8
  import { hasTotp, securityVersion, sessionIsSecure } from "../auth/security.js";
9
- import { verifyPassword } from "../auth/password.js";
9
+ import { verifyPasswordOrDummy } from "../auth/password.js";
10
10
  import { findUserByEmail, findUserById } from "../auth/users.js";
11
11
  import { ISSUER, MCP_RESOURCE } from "./constants.js";
12
12
  import { getOAuthHost } from "./host.js";
@@ -115,7 +115,15 @@ function basicClientId(req) {
115
115
  if (!header?.startsWith("Basic "))
116
116
  return null;
117
117
  const [id] = Buffer.from(header.slice(6), "base64").toString("utf8").split(":");
118
- return id ? decodeURIComponent(id) : null;
118
+ if (!id)
119
+ return null;
120
+ try {
121
+ return decodeURIComponent(id);
122
+ }
123
+ catch {
124
+ // A malformed escape is a client that does not exist, not a server error.
125
+ return null;
126
+ }
119
127
  }
120
128
  const str = (value) => (typeof value === "string" ? value : "");
121
129
  function tokenResponse(res, tokens, scope) {
@@ -312,7 +320,8 @@ oauthAuthorizeRouter.post("/oauth/authorize/login", sameOriginOnly, loginLimiter
312
320
  return;
313
321
  }
314
322
  const user = findUserByEmail(parsed.data.email);
315
- if (!user || !verifyPassword(parsed.data.password, user.password_hash)) {
323
+ const passwordMatches = verifyPasswordOrDummy(parsed.data.password, user?.password_hash);
324
+ if (!user || !passwordMatches) {
316
325
  page(res, 401, renderLogin(req, { clientName: client.client_name, returnTo, failed: true }));
317
326
  return;
318
327
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,100 +0,0 @@
1
- import { dirname, join, sep } from "node:path";
2
- import { fileURLToPath } from "node:url";
3
- import { existsSync } from "node:fs";
4
- import express from "express";
5
- import helmet from "helmet";
6
- import cors from "cors";
7
- import cookieSession from "cookie-session";
8
- import rateLimit from "express-rate-limit";
9
- import { config } from "./config.js";
10
- import { runMigrations } from "./db/index.js";
11
- import { apiRouter } from "./api/index.js";
12
- import { mcpRouter } from "./mcp/http.js";
13
- import { startPollingScheduler } from "./feeds/poller.js";
14
- import { startCleanupScheduler } from "./feeds/cleanup.js";
15
- import { repairEncodedText } from "./feeds/repository.js";
16
- import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
17
- import { startEditionScheduler } from "./native/editions.js";
18
- const __dirname = dirname(fileURLToPath(import.meta.url));
19
- export const DEFAULT_WEB_DIST = join(__dirname, "../../web/dist");
20
- export function startCoreSchedulers() {
21
- runMigrations();
22
- const repairedItems = repairEncodedText();
23
- if (repairedItems > 0)
24
- console.log(`[db] decoded HTML entities in ${repairedItems} stored items`);
25
- pruneArchive();
26
- const pendingArchives = scheduleMissingArchives();
27
- if (pendingArchives > 0)
28
- console.log(`[archive] archiving ${pendingArchives} saved articles in the background`);
29
- startPollingScheduler();
30
- startCleanupScheduler();
31
- startEditionScheduler();
32
- }
33
- export function createApp(options = {}) {
34
- const app = express();
35
- if (config.trustProxy) {
36
- app.set("trust proxy", 1);
37
- }
38
- app.use(helmet({
39
- contentSecurityPolicy: {
40
- directives: {
41
- ...helmet.contentSecurityPolicy.getDefaultDirectives(),
42
- "script-src": ["'self'"],
43
- "img-src": ["'self'", "data:", "https:", "http:"],
44
- },
45
- },
46
- }));
47
- // Send X-Robots-Tag to ensure all HTTP endpoints disallow indexing even outside HTML
48
- app.use((_req, res, next) => {
49
- res.setHeader("X-Robots-Tag", "noindex, nofollow");
50
- next();
51
- });
52
- app.use(cors({
53
- origin: config.publicUrl,
54
- credentials: true,
55
- }));
56
- app.use(express.json({ limit: "1mb" }));
57
- app.use(express.text({ type: ["application/xml", "text/xml", "text/plain"], limit: "5mb" }));
58
- app.use(cookieSession({
59
- name: "feedkeeper.sid",
60
- secret: config.sessionSecret,
61
- maxAge: 30 * 24 * 60 * 60 * 1000,
62
- httpOnly: true,
63
- sameSite: "lax",
64
- secure: config.publicUrl.startsWith("https://"),
65
- }));
66
- // Generic API rate limit as defense in depth on top of the stricter
67
- // per-route limiter on /api/auth/login.
68
- app.use("/api", rateLimit({
69
- windowMs: 60 * 1000,
70
- limit: 120,
71
- standardHeaders: true,
72
- legacyHeaders: false,
73
- // Lists load many article images and feed icons at once; those routes have their own, higher limit.
74
- skip: (req) => /^\/(v1\/)?(items\/\d+\/image|feeds\/\d+\/icon|subscriptions\/\d+\/icon|archive\/images\/\d+)$/.test(req.path),
75
- }));
76
- app.use("/api", apiRouter);
77
- app.use("/mcp", mcpRouter);
78
- if (options.configure) {
79
- options.configure(app);
80
- }
81
- const webDist = options.webDist === undefined ? DEFAULT_WEB_DIST : options.webDist;
82
- if (webDist && existsSync(webDist)) {
83
- app.use(express.static(webDist, {
84
- setHeaders(res, filePath) {
85
- // Hashed build files never change; the service worker must always be re-checked.
86
- if (filePath.includes(`${sep}assets${sep}`))
87
- res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
88
- if (filePath.endsWith(`${sep}sw.js`))
89
- res.setHeader("Cache-Control", "no-cache");
90
- },
91
- }));
92
- app.get(/(.*)/, (req, res, next) => {
93
- if (req.path.startsWith("/api") || req.path.startsWith("/mcp") || req.path.startsWith("/cloud"))
94
- return next();
95
- res.sendFile(join(webDist, "index.html"));
96
- });
97
- }
98
- return app;
99
- }
100
- //# sourceMappingURL=app.js.map
@@ -1,58 +0,0 @@
1
- const DEFAULT_SELFHOSTED_FEATURES = [
2
- "sync",
3
- "notes",
4
- "edition",
5
- "edition.automatic",
6
- "edition.revisions",
7
- "native-preferences",
8
- "search.fts",
9
- "mcp.remote",
10
- "feed-icons",
11
- "folder-icons",
12
- "fulltext",
13
- ];
14
- class DefaultPlanProvider {
15
- getPlanForUser(_userId) {
16
- return {
17
- tier: "selfhosted",
18
- status: "active",
19
- features: DEFAULT_SELFHOSTED_FEATURES,
20
- manageUrl: null,
21
- };
22
- }
23
- hasFeature(_userId, _feature) {
24
- // In self-hosted mode, all features are permitted
25
- return true;
26
- }
27
- }
28
- let activePlanProvider = new DefaultPlanProvider();
29
- export function setPlanProvider(provider) {
30
- activePlanProvider = provider;
31
- }
32
- export function getPlanProvider() {
33
- return activePlanProvider;
34
- }
35
- export function getUserPlan(userId) {
36
- return activePlanProvider.getPlanForUser(userId);
37
- }
38
- export function requirePlanFeature(feature) {
39
- return (req, res, next) => {
40
- const userId = req.user?.id;
41
- if (!userId) {
42
- res.status(401).json({ error: "not_authenticated" });
43
- return;
44
- }
45
- if (!activePlanProvider.hasFeature(userId, feature)) {
46
- const plan = activePlanProvider.getPlanForUser(userId);
47
- res.status(403).json({
48
- error: "feature_not_included",
49
- feature,
50
- tier: plan.tier,
51
- manageUrl: plan.manageUrl ?? null,
52
- });
53
- return;
54
- }
55
- next();
56
- };
57
- }
58
- //# sourceMappingURL=plan.js.map
@@ -1,15 +0,0 @@
1
- export { createApp, startCoreSchedulers, DEFAULT_WEB_DIST } from "./app.js";
2
- export * from "./auth/capabilities.js";
3
- export * from "./auth/tokens.js";
4
- export * from "./auth/middleware.js";
5
- export * from "./auth/users.js";
6
- export * from "./config.js";
7
- export * from "./db/index.js";
8
- export { apiRouter } from "./api/index.js";
9
- export { mcpRouter } from "./mcp/http.js";
10
- export { startPollingScheduler } from "./feeds/poller.js";
11
- export { startCleanupScheduler } from "./feeds/cleanup.js";
12
- export { repairEncodedText } from "./feeds/repository.js";
13
- export { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
14
- export { startEditionScheduler } from "./native/editions.js";
15
- //# sourceMappingURL=index.js.map