@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/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
- const person = await this.identify(String(body.person_token ?? "").trim());
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
- return info(token, grant.client_id, grant.scopes, grant.expires_at, grant.person, grant.resource);
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
- const cached = this.direct.get(digest(token));
173
- if (cached && cached.until > now()) {
174
- return info(token, "direct", DIRECT_SCOPES, cached.until, cached.person);
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
- const until = now() + DIRECT_TTL;
180
- this.direct.set(digest(token), { person, until });
181
- return info(token, "direct", DIRECT_SCOPES, until, person);
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.</p>
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
- <label><input type="checkbox" name="approve"> Let me approve requests on cards inside this client</label>
267
- <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>
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
- return null;
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
- app.post("/authorize/confirm", express.urlencoded({ extended: false, limit: "16kb" }), (req, res, next) => {
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
@@ -13,6 +13,8 @@ export declare class Ledger<R extends {
13
13
  id: string;
14
14
  }> {
15
15
  private readonly path;
16
+ /** The parsed file, reused until its size or modification time changes. */
17
+ private cache;
16
18
  constructor(path: string);
17
19
  append(record: R): R;
18
20
  all(): R[];
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
- return out;
86
+ this.cache = { size: st.size, mtimeMs: st.mtimeMs, records: out };
87
+ return [...out];
82
88
  }
83
89
  }
84
90
  export function statePaths(dir) {