@miadi/connector 0.1.6 → 0.1.7
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/README.md +11 -7
- package/dist/approve.d.ts +6 -1
- package/dist/approve.js +130 -62
- package/dist/decisions.d.ts +1 -1
- package/dist/decisions.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/miadi.d.ts +1 -1
- package/dist/miadi.js +2 -1
- package/dist/oauth.d.ts +15 -0
- package/dist/oauth.js +101 -16
- package/dist/people.d.ts +3 -0
- package/dist/people.js +8 -2
- package/dist/reviews.js +2 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +10 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.js +8 -2
- package/dist/tools.js +89 -52
- package/dist/uploads.d.ts +3 -0
- package/dist/uploads.js +18 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/oauth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
1
|
+
import { InvalidGrantError, InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
2
2
|
import { escapeHtml } from "@miadi/node-service-kit/format";
|
|
3
3
|
import { digest, newSecret } from "./store.js";
|
|
4
|
+
import { MiadiUnavailable } from "./people.js";
|
|
4
5
|
export const SCOPE_READ = "miadi:read";
|
|
5
6
|
export const SCOPE_CONTRIBUTE = "miadi:contribute";
|
|
6
7
|
/**
|
|
@@ -18,13 +19,23 @@ const REFRESH_TTL = 90 * 24 * 3600;
|
|
|
18
19
|
const CODE_TTL = 600;
|
|
19
20
|
const PENDING_TTL = 1800;
|
|
20
21
|
const DIRECT_TTL = 300;
|
|
22
|
+
const CLIENT_IDLE = 30 * 24 * 3600;
|
|
23
|
+
/**
|
|
24
|
+
* Where a client must send people back to before its sign-in may carry
|
|
25
|
+
* miadi:approve. The in-chat card's protections (a tool hidden from the model,
|
|
26
|
+
* a token kept out of the model's context) are host behaviours; these are the
|
|
27
|
+
* hosts known to keep them.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_APPROVE_REDIRECT_HOSTS = ["chatgpt.com", "chat.openai.com"];
|
|
21
30
|
const now = () => Math.floor(Date.now() / 1000);
|
|
22
31
|
export class MiadiOAuthProvider {
|
|
23
32
|
state;
|
|
24
33
|
identify;
|
|
25
34
|
issuer;
|
|
26
35
|
confirmPath;
|
|
36
|
+
/** What Miadi answered for a person token, kept 5 minutes: direct Bearer use and re-checks of signed-in grants. */
|
|
27
37
|
direct = new Map();
|
|
38
|
+
approveRedirectHosts = DEFAULT_APPROVE_REDIRECT_HOSTS;
|
|
28
39
|
constructor(state, identify,
|
|
29
40
|
/**
|
|
30
41
|
* The issuer as the metadata states it. It goes back on every authorization
|
|
@@ -69,7 +80,7 @@ export class MiadiOAuthProvider {
|
|
|
69
80
|
created_at: now(),
|
|
70
81
|
};
|
|
71
82
|
});
|
|
72
|
-
res.status(200).type("html").send(signInPage({ requestId, client, scopes: wanted(params.scopes), action: this.confirmPath }));
|
|
83
|
+
res.status(200).type("html").send(signInPage({ requestId, client, scopes: wanted(params.scopes), action: this.confirmPath, redirectUri: params.redirectUri, approvable: this.approvable(client) }));
|
|
73
84
|
}
|
|
74
85
|
/** POST from the sign-in page: the person's token, or a refusal. */
|
|
75
86
|
async confirm(req, res) {
|
|
@@ -92,7 +103,16 @@ export class MiadiOAuthProvider {
|
|
|
92
103
|
res.redirect(302, redirect.href);
|
|
93
104
|
return;
|
|
94
105
|
}
|
|
95
|
-
|
|
106
|
+
let person;
|
|
107
|
+
try {
|
|
108
|
+
person = await this.identify(String(body.person_token ?? "").trim());
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (!(error instanceof MiadiUnavailable))
|
|
112
|
+
throw error;
|
|
113
|
+
res.status(503).type("html").send(signInPage({ requestId: body.request_id, client, scopes: wanted(pending.params.scopes), action: this.confirmPath, redirectUri: pending.params.redirectUri, approvable: this.approvable(client), error: "Miadi could not be reached. Try again in a minute." }));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
96
116
|
if (!person) {
|
|
97
117
|
res
|
|
98
118
|
.status(401)
|
|
@@ -102,6 +122,8 @@ export class MiadiOAuthProvider {
|
|
|
102
122
|
client,
|
|
103
123
|
scopes: wanted(pending.params.scopes),
|
|
104
124
|
action: this.confirmPath,
|
|
125
|
+
redirectUri: pending.params.redirectUri,
|
|
126
|
+
approvable: this.approvable(client),
|
|
105
127
|
error: "Miadi did not recognise that person token.",
|
|
106
128
|
}));
|
|
107
129
|
return;
|
|
@@ -110,7 +132,7 @@ export class MiadiOAuthProvider {
|
|
|
110
132
|
if (body.contribute !== "on")
|
|
111
133
|
scopes = scopes.filter((x) => x !== SCOPE_CONTRIBUTE);
|
|
112
134
|
// In-chat approval is opt-in, whatever the client asked for, and only for Miadi's write tier.
|
|
113
|
-
if (body.approve === "on" && person.api_tier === "write")
|
|
135
|
+
if (body.approve === "on" && person.api_tier === "write" && this.approvable(client))
|
|
114
136
|
scopes.push(SCOPE_APPROVE);
|
|
115
137
|
if (!scopes.includes(SCOPE_READ))
|
|
116
138
|
scopes.unshift(SCOPE_READ);
|
|
@@ -125,6 +147,7 @@ export class MiadiOAuthProvider {
|
|
|
125
147
|
resource: pending.params.resource,
|
|
126
148
|
codeChallenge: pending.params.codeChallenge,
|
|
127
149
|
redirectUri: pending.params.redirectUri,
|
|
150
|
+
signed_in_at: now(),
|
|
128
151
|
};
|
|
129
152
|
});
|
|
130
153
|
redirect.searchParams.set("code", code);
|
|
@@ -158,6 +181,9 @@ export class MiadiOAuthProvider {
|
|
|
158
181
|
if (!grant || grant.kind !== "refresh" || grant.client_id !== client.client_id || grant.expires_at < now()) {
|
|
159
182
|
throw new InvalidGrantError("Refresh token is invalid or expired");
|
|
160
183
|
}
|
|
184
|
+
if (grant.signed_in_at && now() - grant.signed_in_at > REFRESH_TTL) {
|
|
185
|
+
throw new InvalidGrantError("This sign-in is more than 90 days old; sign in again");
|
|
186
|
+
}
|
|
161
187
|
const narrowed = scopes?.length ? grant.scopes.filter((x) => scopes.includes(x)) : grant.scopes;
|
|
162
188
|
return this.issue({ ...grant, scopes: narrowed });
|
|
163
189
|
}
|
|
@@ -166,19 +192,64 @@ export class MiadiOAuthProvider {
|
|
|
166
192
|
if (grant && grant.kind === "access") {
|
|
167
193
|
if (grant.expires_at < now())
|
|
168
194
|
throw new InvalidTokenError("Access token expired");
|
|
169
|
-
|
|
195
|
+
// Miadi is asked again (at most every 5 minutes) who the person is now: a
|
|
196
|
+
// revoked token ends the sign-in, and a lost write tier ends in-chat approval.
|
|
197
|
+
let person = grant.person;
|
|
198
|
+
try {
|
|
199
|
+
const fresh = await this.lookup(grant.person.token);
|
|
200
|
+
if (!fresh)
|
|
201
|
+
throw new InvalidTokenError("Miadi no longer recognises the person behind this sign-in");
|
|
202
|
+
person = fresh;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!(error instanceof MiadiUnavailable))
|
|
206
|
+
throw error;
|
|
207
|
+
// During a short Miadi outage the sign-in keeps the person as last known.
|
|
208
|
+
}
|
|
209
|
+
const scopes = person.api_tier === "write" ? grant.scopes : grant.scopes.filter((x) => x !== SCOPE_APPROVE);
|
|
210
|
+
return info(token, grant.client_id, scopes, grant.expires_at, person, grant.resource);
|
|
170
211
|
}
|
|
171
212
|
// A Miadi person token used directly (terminal clients).
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
let person;
|
|
214
|
+
try {
|
|
215
|
+
person = await this.lookup(token);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error instanceof MiadiUnavailable)
|
|
219
|
+
throw new ServerError("Miadi could not be reached; try again shortly");
|
|
220
|
+
throw error;
|
|
175
221
|
}
|
|
176
|
-
const person = await this.identify(token);
|
|
177
222
|
if (!person)
|
|
178
223
|
throw new InvalidTokenError("Unknown token");
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
224
|
+
return info(token, "direct", DIRECT_SCOPES, this.direct.get(digest(token)).until, person);
|
|
225
|
+
}
|
|
226
|
+
/** Miadi's answer for a person token, from the 5-minute cache or fresh. */
|
|
227
|
+
async lookup(token) {
|
|
228
|
+
const key = digest(token);
|
|
229
|
+
const cached = this.direct.get(key);
|
|
230
|
+
if (cached && cached.until > now())
|
|
231
|
+
return cached.person;
|
|
232
|
+
const person = await this.identify(token);
|
|
233
|
+
if (person)
|
|
234
|
+
this.direct.set(key, { person, until: now() + DIRECT_TTL });
|
|
235
|
+
else
|
|
236
|
+
this.direct.delete(key);
|
|
237
|
+
return person;
|
|
238
|
+
}
|
|
239
|
+
/** May this client's sign-in carry miadi:approve: does it only send people back to a known host? */
|
|
240
|
+
approvable(client) {
|
|
241
|
+
const uris = client?.redirect_uris ?? [];
|
|
242
|
+
if (!uris.length)
|
|
243
|
+
return false;
|
|
244
|
+
return uris.every((u) => {
|
|
245
|
+
try {
|
|
246
|
+
const url = new URL(String(u));
|
|
247
|
+
return url.protocol === "https:" && this.approveRedirectHosts.includes(url.hostname);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
182
253
|
}
|
|
183
254
|
async revokeToken(_client, request) {
|
|
184
255
|
this.state.update((s) => {
|
|
@@ -190,7 +261,7 @@ export class MiadiOAuthProvider {
|
|
|
190
261
|
const refresh = newSecret();
|
|
191
262
|
this.state.update((s) => {
|
|
192
263
|
prune(s);
|
|
193
|
-
const base = { client_id: grant.client_id, person: grant.person, scopes: grant.scopes, resource: grant.resource };
|
|
264
|
+
const base = { client_id: grant.client_id, person: grant.person, scopes: grant.scopes, resource: grant.resource, signed_in_at: grant.signed_in_at ?? now() };
|
|
194
265
|
s.tokens[digest(access)] = { ...base, kind: "access", expires_at: now() + ACCESS_TTL };
|
|
195
266
|
s.tokens[digest(refresh)] = { ...base, kind: "refresh", expires_at: now() + REFRESH_TTL };
|
|
196
267
|
});
|
|
@@ -228,6 +299,11 @@ function prune(s) {
|
|
|
228
299
|
for (const [k, v] of Object.entries(s.tokens))
|
|
229
300
|
if (v.expires_at < t)
|
|
230
301
|
delete s.tokens[k];
|
|
302
|
+
// A registered client that holds nothing and was registered over 30 days ago is forgotten.
|
|
303
|
+
const holding = new Set([...Object.values(s.pending), ...Object.values(s.codes), ...Object.values(s.tokens)].map((x) => x.client_id));
|
|
304
|
+
for (const [k, c] of Object.entries(s.clients))
|
|
305
|
+
if (!holding.has(k) && t - Number(c.client_id_issued_at ?? 0) > CLIENT_IDLE)
|
|
306
|
+
delete s.clients[k];
|
|
231
307
|
}
|
|
232
308
|
function page(title, body) {
|
|
233
309
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -250,9 +326,16 @@ button.primary{border-color:var(--accent);outline:1px solid var(--accent)}
|
|
|
250
326
|
}
|
|
251
327
|
function signInPage(o) {
|
|
252
328
|
const name = o.client?.client_name || o.client?.client_id || "An MCP client";
|
|
329
|
+
let host = "";
|
|
330
|
+
try {
|
|
331
|
+
host = o.redirectUri ? new URL(o.redirectUri).host : "";
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
/* no host to show */
|
|
335
|
+
}
|
|
253
336
|
const contribute = o.scopes.includes(SCOPE_CONTRIBUTE);
|
|
254
337
|
return page("Sign in to Miadi", `<h1>Sign in to Miadi</h1>
|
|
255
|
-
<p><strong>${escapeHtml(name)}</strong> asks to use Miadi as you
|
|
338
|
+
<p><strong>${escapeHtml(name)}</strong> asks to use Miadi as you.${host ? ` After you sign in you return to <strong>${escapeHtml(host)}</strong>.` : ""}</p>
|
|
256
339
|
<ul>
|
|
257
340
|
<li>Read sessions, reviews, episodes, circles and the records people contributed.</li>
|
|
258
341
|
${contribute ? "<li>Contribute in your name: observations, inquiries, links, handoffs and proposals. Nothing it proposes runs, publishes or closes without a person's approval.</li>" : ""}
|
|
@@ -263,8 +346,10 @@ ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
|
|
|
263
346
|
<label for="person_token">Your Miadi person token</label>
|
|
264
347
|
<input id="person_token" name="person_token" type="password" autocomplete="off" required>
|
|
265
348
|
${contribute ? '<label><input type="checkbox" name="contribute" checked> Allow contributions in my name</label>' : ""}
|
|
266
|
-
|
|
267
|
-
|
|
349
|
+
${o.approvable
|
|
350
|
+
? `<label><input type="checkbox" name="approve"> Let me approve requests on cards inside this client</label>
|
|
351
|
+
<p>Only for people with Miadi's write tier. Each approval is your own click on a card; the assistant cannot approve for you.</p>`
|
|
352
|
+
: ""}
|
|
268
353
|
<div class="row">
|
|
269
354
|
<button class="primary" type="submit" name="decision" value="allow">Sign in</button>
|
|
270
355
|
<button type="submit" name="decision" value="deny" formnovalidate>Refuse</button>
|
package/dist/people.d.ts
CHANGED
|
@@ -12,4 +12,7 @@ export interface MiadiPerson extends Person {
|
|
|
12
12
|
api_tier: ApiTier;
|
|
13
13
|
permissions: string[];
|
|
14
14
|
}
|
|
15
|
+
/** Miadi could not be asked: not the same answer as "this token is nobody's". */
|
|
16
|
+
export declare class MiadiUnavailable extends Error {
|
|
17
|
+
}
|
|
15
18
|
export declare function identifyThroughMiadi(miadiUrl: string, fetchImpl?: typeof fetch): (token: string) => Promise<MiadiPerson | null>;
|
package/dist/people.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Miadi could not be asked: not the same answer as "this token is nobody's". */
|
|
2
|
+
export class MiadiUnavailable extends Error {
|
|
3
|
+
}
|
|
1
4
|
export function identifyThroughMiadi(miadiUrl, fetchImpl = fetch) {
|
|
2
5
|
return async (token) => {
|
|
3
6
|
if (!token || token.length < 16)
|
|
@@ -6,11 +9,14 @@ export function identifyThroughMiadi(miadiUrl, fetchImpl = fetch) {
|
|
|
6
9
|
try {
|
|
7
10
|
res = await fetchImpl(`${miadiUrl}/api/identity/me`, {
|
|
8
11
|
headers: { accept: "application/json", authorization: `Bearer ${token}` },
|
|
12
|
+
signal: AbortSignal.timeout(10_000),
|
|
9
13
|
});
|
|
10
14
|
}
|
|
11
|
-
catch {
|
|
12
|
-
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new MiadiUnavailable(error instanceof Error ? error.message : String(error));
|
|
13
17
|
}
|
|
18
|
+
if (res.status >= 500)
|
|
19
|
+
throw new MiadiUnavailable(`Miadi answered ${res.status}`);
|
|
14
20
|
if (!res.ok)
|
|
15
21
|
return null;
|
|
16
22
|
const me = await res.json().catch(() => null);
|
package/dist/reviews.js
CHANGED
|
@@ -62,7 +62,7 @@ export class ReviewClient {
|
|
|
62
62
|
/** `/review/<id>/json`, optionally pinned to a version. Null on 404. */
|
|
63
63
|
async get(reviewId, version) {
|
|
64
64
|
const q = version ? `?v=${version}` : "";
|
|
65
|
-
const res = await this.fetchImpl(`${this.baseUrl}/review/${reviewId}/json${q}`, { headers: { accept: "application/json" } });
|
|
65
|
+
const res = await this.fetchImpl(`${this.baseUrl}/review/${reviewId}/json${q}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
|
|
66
66
|
if (res.status === 404)
|
|
67
67
|
return null;
|
|
68
68
|
if (!res.ok)
|
|
@@ -74,6 +74,7 @@ export class ReviewClient {
|
|
|
74
74
|
throw new Error("MIADI_REVIEW_TOKEN is not configured on the connector host");
|
|
75
75
|
const res = await this.fetchImpl(`${this.baseUrl}/api/reviews/search?${new URLSearchParams({ q, limit: String(limit) })}`, {
|
|
76
76
|
headers: { accept: "application/json", authorization: `Bearer ${this.token}` },
|
|
77
|
+
signal: AbortSignal.timeout(15_000),
|
|
77
78
|
});
|
|
78
79
|
if (!res.ok)
|
|
79
80
|
throw new Error(`review search answered ${res.status}`);
|
package/dist/server.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface ConnectorDeps {
|
|
|
9
9
|
/** The factory directory holding queue/ (default ~/.miadi/factory). */
|
|
10
10
|
factoryDir?: string;
|
|
11
11
|
tmuxSessions?: () => string[];
|
|
12
|
+
/** Person-token attempts allowed per address in 15 minutes on each sign-in form (default 30). */
|
|
13
|
+
tokenAttemptsPerWindow?: number;
|
|
12
14
|
}
|
|
13
15
|
export declare function createConnectorApp(config: ConnectorConfig, deps: ConnectorDeps): {
|
|
14
16
|
app: import("express-serve-static-core").Express;
|
package/dist/server.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* as that person (see tools.ts).
|
|
11
11
|
*/
|
|
12
12
|
import express from "express";
|
|
13
|
+
import { rateLimit } from "express-rate-limit";
|
|
13
14
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
15
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
16
|
import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
@@ -65,7 +66,15 @@ export function createConnectorApp(config, deps) {
|
|
|
65
66
|
resourceName: "Miadi",
|
|
66
67
|
serviceDocumentationUrl: new URL("https://github.com/jgwill/Miadi/tree/main/packages/connector"),
|
|
67
68
|
}));
|
|
68
|
-
|
|
69
|
+
// The forms that take a person token are limited per address, and so are
|
|
70
|
+
// failed /mcp authentications (a person token may be sent there directly).
|
|
71
|
+
const attempts = deps.tokenAttemptsPerWindow ?? 30;
|
|
72
|
+
const limitTokens = () => rateLimit({ windowMs: 15 * 60 * 1000, limit: attempts, standardHeaders: "draft-7", legacyHeaders: false });
|
|
73
|
+
const confirmLimit = limitTokens();
|
|
74
|
+
const formLimit = limitTokens();
|
|
75
|
+
app.use(["/approve", "/records"], (req, res, next) => (req.method === "POST" ? formLimit(req, res, next) : next()));
|
|
76
|
+
app.use("/mcp", rateLimit({ windowMs: 15 * 60 * 1000, limit: attempts, skipSuccessfulRequests: true, standardHeaders: "draft-7", legacyHeaders: false }));
|
|
77
|
+
app.post("/authorize/confirm", confirmLimit, express.urlencoded({ extended: false, limit: "16kb" }), (req, res, next) => {
|
|
69
78
|
provider.confirm(req, res).catch(next);
|
|
70
79
|
});
|
|
71
80
|
const approvalDeps = {
|
package/dist/store.d.ts
CHANGED
package/dist/store.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* (observations, handoffs, proposals). Append-only: a record
|
|
11
11
|
* is never edited, a later record supersedes it.
|
|
12
12
|
*/
|
|
13
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { createHash, randomBytes } from "node:crypto";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { readJsonSafe, writeJsonAtomic } from "@miadi/node-service-kit/json";
|
|
@@ -57,6 +57,8 @@ export class JsonFile {
|
|
|
57
57
|
}
|
|
58
58
|
export class Ledger {
|
|
59
59
|
path;
|
|
60
|
+
/** The parsed file, reused until its size or modification time changes. */
|
|
61
|
+
cache = null;
|
|
60
62
|
constructor(path) {
|
|
61
63
|
this.path = path;
|
|
62
64
|
}
|
|
@@ -67,6 +69,9 @@ export class Ledger {
|
|
|
67
69
|
all() {
|
|
68
70
|
if (!existsSync(this.path))
|
|
69
71
|
return [];
|
|
72
|
+
const st = statSync(this.path);
|
|
73
|
+
if (this.cache && this.cache.size === st.size && this.cache.mtimeMs === st.mtimeMs)
|
|
74
|
+
return [...this.cache.records];
|
|
70
75
|
const out = [];
|
|
71
76
|
for (const line of readFileSync(this.path, "utf8").split("\n")) {
|
|
72
77
|
if (!line.trim())
|
|
@@ -78,7 +83,8 @@ export class Ledger {
|
|
|
78
83
|
/* a torn last line from a crash is skipped, not fatal */
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
|
-
|
|
86
|
+
this.cache = { size: st.size, mtimeMs: st.mtimeMs, records: out };
|
|
87
|
+
return [...out];
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
export function statePaths(dir) {
|