create-evda 0.1.4 → 0.1.6

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 (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +93 -8
  3. package/bin/evda.mjs +437 -5
  4. package/create-evda.mjs +68 -12
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +351 -19
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +22 -8
  17. package/lib/gather-apply.mjs +635 -0
  18. package/lib/gather.mjs +808 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +467 -35
  29. package/lib/migrate-people.mjs +231 -0
  30. package/lib/organize.mjs +52 -12
  31. package/lib/parity.mjs +111 -0
  32. package/lib/presets.mjs +379 -0
  33. package/lib/prompt-hook.mjs +88 -0
  34. package/lib/publish.mjs +264 -0
  35. package/lib/readers.mjs +319 -0
  36. package/lib/records.mjs +52 -0
  37. package/lib/references.mjs +5 -51
  38. package/lib/render.mjs +158 -0
  39. package/lib/roles.mjs +102 -1
  40. package/lib/secret-shape.mjs +58 -0
  41. package/lib/setup.mjs +146 -2
  42. package/lib/skills.mjs +266 -0
  43. package/lib/subrecord.mjs +121 -0
  44. package/lib/sync-rules.mjs +87 -0
  45. package/lib/tools.mjs +120 -29
  46. package/lib/update-check.mjs +146 -0
  47. package/lib/work.mjs +180 -0
  48. package/lib/yaml.mjs +117 -42
  49. package/package.json +25 -15
  50. package/presets/journal/preset.json +19 -0
  51. package/presets/projects-and-areas/preset.json +34 -0
  52. package/skills/evda/SKILL.md +76 -0
  53. package/templates/AGENTS.md +129 -15
  54. package/templates/README.md +15 -1
  55. package/templates/circles-README.md +3 -3
  56. package/templates/evda-digest.md +32 -5
  57. package/LICENSE +0 -21
@@ -0,0 +1,370 @@
1
+ // The authorization server behind `evda mcp --http` (HQ-1378): OAuth 2.1 for one owner, so the
2
+ // Claude apps can reach a record from Anthropic's cloud and nobody else can.
3
+ //
4
+ // What a claude.ai custom connector needs, and nothing more: RFC 9728 protected resource metadata,
5
+ // RFC 8414 authorization server metadata, RFC 7591 dynamic client registration of public clients,
6
+ // the authorization code grant with PKCE (S256 only), rotating refresh tokens, RFC 7009 revocation.
7
+ //
8
+ // There are no accounts and no passwords. The one person who may approve a client is whoever holds
9
+ // the machine: `evda mcp token --new` prints a one-time owner code there, and the authorization
10
+ // page asks for it. A code works once, expires, and is kept only as its hash.
11
+ //
12
+ // Everything is on disk under ~/.evda/<record id>/mcp-auth/, the directory 0700 and every file
13
+ // 0600, written by rename so a crash cannot leave half a file. Tokens, codes and owner codes are
14
+ // stored as SHA-256 hashes, so reading the state file hands nobody a working credential.
15
+ //
16
+ // The test hooks. Each one breaks exactly one refusal so the tests can be watched failing.
17
+ // EVDA_MCP_FALSIFY=no-token-check /mcp answers without a token (read in lib/mcp-http.mjs)
18
+ // EVDA_MCP_FALSIFY=accept-expired an expired access token is accepted
19
+ // EVDA_MCP_FALSIFY=accept-revoked revocation removes nothing
20
+ // EVDA_MCP_FALSIFY=skip-pkce the code verifier is not checked
21
+ // EVDA_MCP_FALSIFY=skip-redirect-check the redirect URI is not compared, at /authorize or /token
22
+ // EVDA_MCP_FALSIFY=log-content the call log carries the tool arguments (lib/mcp-http.mjs)
23
+ // EVDA_MCP_FALSIFY=log-args every tool call logs its arguments, stdio and HTTP (lib/mcp.mjs)
24
+
25
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
26
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { EvdaError } from "./errors.mjs";
30
+
31
+ export const FALSIFY = (mode, env = process.env) => String(env.EVDA_MCP_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
32
+
33
+ export const ACCESS_TTL_MS = 60 * 60 * 1000; // one hour; Claude refreshes on a 401
34
+ export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // thirty days, renewed on every rotation
35
+ export const CODE_TTL_MS = 5 * 60 * 1000;
36
+ export const OWNER_CODE_TTL_MS = 24 * 60 * 60 * 1000;
37
+ export const MAX_CLIENTS = 50;
38
+ export const SCOPE = "evda";
39
+
40
+ // Where a client may send the owner back. The hosted Claude surfaces (web, desktop, mobile) use the
41
+ // first two; Claude Code uses a loopback redirect on a port it picks each session, so a loopback
42
+ // URI matches with the port ignored (RFC 8252 section 7.3). A registration naming anything else is
43
+ // refused, so an authorization code can only ever travel to Claude or to this machine.
44
+ export const HOSTED_REDIRECTS = ["https://claude.ai/api/mcp/auth_callback", "https://claude.com/api/mcp/auth_callback"];
45
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
46
+
47
+ export const sha256 = (s) => createHash("sha256").update(s).digest("hex");
48
+ const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
49
+ export const newSecret = (bytes = 32) => b64url(randomBytes(bytes));
50
+
51
+ // A code a person types from one screen into another: 16 characters in four groups, from an alphabet
52
+ // with no 0/O or 1/I/L, about 80 bits.
53
+ const OWNER_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
54
+ function ownerCodeString() {
55
+ const bytes = randomBytes(16);
56
+ let s = "";
57
+ for (const b of bytes) s += OWNER_ALPHABET[b % OWNER_ALPHABET.length];
58
+ return s.match(/.{4}/g).join("-");
59
+ }
60
+ const normalizeOwnerCode = (s) => String(s ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
61
+
62
+ // ~/.evda/<record id>/mcp-auth, the same parent as the device key file (HQ-1219).
63
+ export function authDir(evda, env = process.env) {
64
+ const id = String(evda.manifest?.id ?? evda.manifest?.name ?? "").trim();
65
+ if (!/^[A-Za-z0-9._-]+$/.test(id)) throw new EvdaError(`REFUSED: this record's evda.json has no usable "id" (got ${JSON.stringify(id)}), so there is no place to keep the remote server's authorization state.`, { code: "NO_RECORD_ID", exit: 2 });
66
+ return join(env.HOME ?? homedir(), ".evda", id, "mcp-auth");
67
+ }
68
+
69
+ const EMPTY = () => ({ v: 1, clients: {}, codes: {}, access: {}, refresh: {}, owner: {} });
70
+
71
+ // The state is one small JSON file. Every change is a read, a change and a rename under a lock
72
+ // directory, so the server and `evda mcp token` on the same machine never lose each other's writes.
73
+ export function openStore(dir) {
74
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ chmodSync(dir, 0o700);
76
+ const file = join(dir, "state.json");
77
+ const lock = join(dir, "state.lock");
78
+ const read = () => {
79
+ if (!existsSync(file)) return EMPTY();
80
+ const mode = statSync(file).mode & 0o777;
81
+ if (mode !== 0o600) throw new EvdaError(`REFUSED: ${file} has mode ${mode.toString(8)}, not 600. Something other than evda wrote it; check it, then chmod 600 it.`, { code: "BAD_MODE", exit: 2 });
82
+ return { ...EMPTY(), ...JSON.parse(readFileSync(file, "utf8")) };
83
+ };
84
+ const write = (state) => {
85
+ const tmp = join(dir, `state.json.${process.pid}.${Date.now()}.tmp`);
86
+ writeFileSync(tmp, JSON.stringify(state, null, 1), { mode: 0o600 });
87
+ chmodSync(tmp, 0o600);
88
+ renameSync(tmp, file);
89
+ };
90
+ const withLock = (fn) => {
91
+ const deadline = Date.now() + 3000;
92
+ for (;;) {
93
+ try { mkdirSync(lock); break; } catch (e) {
94
+ if (e.code !== "EEXIST") throw e;
95
+ try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, { recursive: true, force: true }); } catch { /* gone already */ }
96
+ if (Date.now() > deadline) throw new EvdaError(`REFUSED: ${lock} has been held for over 3 seconds.`, { code: "LOCKED", exit: 1 });
97
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
98
+ }
99
+ }
100
+ try { return fn(); } finally { rmSync(lock, { recursive: true, force: true }); }
101
+ };
102
+ return {
103
+ dir,
104
+ file,
105
+ read,
106
+ update(fn, now = Date.now()) {
107
+ return withLock(() => {
108
+ const s = read();
109
+ prune(s, now);
110
+ const out = fn(s);
111
+ write(s);
112
+ return out;
113
+ });
114
+ },
115
+ };
116
+ }
117
+
118
+ function prune(s, now) {
119
+ for (const k of ["codes", "access", "refresh", "owner"]) {
120
+ for (const [h, r] of Object.entries(s[k])) if (r.expires <= now) delete s[k][h];
121
+ }
122
+ }
123
+
124
+ // ---- the owner code, from `evda mcp token` ----
125
+
126
+ export function newOwnerCode(store, { ttlMs = OWNER_CODE_TTL_MS, now = Date.now() } = {}) {
127
+ const code = ownerCodeString();
128
+ const expires = now + ttlMs;
129
+ store.update((s) => { s.owner[sha256(normalizeOwnerCode(code))] = { created: now, expires }; }, now);
130
+ return { code, expires };
131
+ }
132
+
133
+ // Spend an owner code: true once, for a code that exists and has not expired, and never again.
134
+ export function spendOwnerCode(store, typed, now = Date.now()) {
135
+ const h = sha256(normalizeOwnerCode(typed));
136
+ return store.update((s) => {
137
+ const hit = Object.keys(s.owner).find((k) => timingSafeEqual(Buffer.from(k), Buffer.from(h)));
138
+ if (!hit) return false;
139
+ delete s.owner[hit];
140
+ return true;
141
+ }, now);
142
+ }
143
+
144
+ export function burnOwnerCodes(store, now = Date.now()) {
145
+ return store.update((s) => { const n = Object.keys(s.owner).length; s.owner = {}; return n; }, now);
146
+ }
147
+
148
+ // Everything off at once: every client, code and token. The connector stops at its next call.
149
+ export function revokeAll(store, now = Date.now()) {
150
+ return store.update((s) => {
151
+ const n = { clients: Object.keys(s.clients).length, access: Object.keys(s.access).length, refresh: Object.keys(s.refresh).length, owner: Object.keys(s.owner).length };
152
+ Object.assign(s, EMPTY());
153
+ return n;
154
+ }, now);
155
+ }
156
+
157
+ export function status(store, now = Date.now()) {
158
+ const s = store.read();
159
+ prune(s, now);
160
+ const live = (o) => Object.values(o).filter((r) => !r.used);
161
+ return {
162
+ clients: Object.values(s.clients).map((c) => ({ client_id: c.client_id, client_name: c.client_name ?? null, created: c.created, redirects: c.redirect_uris })),
163
+ access: live(s.access).length,
164
+ refresh: live(s.refresh).length,
165
+ ownerCodes: Object.values(s.owner).map((o) => ({ expires: o.expires })),
166
+ };
167
+ }
168
+
169
+ // ---- registration (RFC 7591) ----
170
+
171
+ const oauthError = (status, error, description) => ({ status, body: { error, error_description: description } });
172
+
173
+ function isLoopback(u) {
174
+ return u.protocol === "http:" && LOOPBACK.has(u.hostname.startsWith("[") ? u.hostname : u.hostname);
175
+ }
176
+
177
+ export function redirectAllowed(uri) {
178
+ if (HOSTED_REDIRECTS.includes(uri)) return true;
179
+ try { const u = new URL(uri); return isLoopback(u) && !u.username && !u.password && !u.hash; } catch { return false; }
180
+ }
181
+
182
+ // Does `asked` match one of the client's registered URIs? Exact, except the port on a loopback URI.
183
+ export function redirectMatches(registered, asked, env = process.env) {
184
+ if (FALSIFY("skip-redirect-check", env)) return true;
185
+ if (typeof asked !== "string") return false;
186
+ if (registered.includes(asked)) return true;
187
+ let a;
188
+ try { a = new URL(asked); } catch { return false; }
189
+ if (!isLoopback(a)) return false;
190
+ return registered.some((r) => {
191
+ try {
192
+ const u = new URL(r);
193
+ return isLoopback(u) && u.hostname === a.hostname && u.pathname === a.pathname && u.search === a.search;
194
+ } catch { return false; }
195
+ });
196
+ }
197
+
198
+ export function registerClient(store, body, { now = Date.now() } = {}) {
199
+ if (!body || typeof body !== "object" || Array.isArray(body)) return oauthError(400, "invalid_client_metadata", "The registration body must be a JSON object.");
200
+ const uris = body.redirect_uris;
201
+ if (!Array.isArray(uris) || uris.length === 0 || uris.length > 10 || !uris.every((u) => typeof u === "string" && u.length < 500)) return oauthError(400, "invalid_redirect_uri", "redirect_uris must be a list of one to ten URIs.");
202
+ const bad = uris.find((u) => !redirectAllowed(u));
203
+ if (bad) return oauthError(400, "invalid_redirect_uri", "This server sends authorization codes only to Claude (claude.ai or claude.com) or to a loopback address on this machine.");
204
+ const grants = body.grant_types ?? ["authorization_code", "refresh_token"];
205
+ if (!Array.isArray(grants) || grants.some((g) => !["authorization_code", "refresh_token"].includes(g))) return oauthError(400, "invalid_client_metadata", "Only the authorization_code and refresh_token grants are supported.");
206
+ const types = body.response_types ?? ["code"];
207
+ if (!Array.isArray(types) || types.some((t) => t !== "code")) return oauthError(400, "invalid_client_metadata", "Only the code response type is supported.");
208
+ const name = typeof body.client_name === "string" ? body.client_name.slice(0, 80) : null;
209
+ const client = {
210
+ client_id: `evda-${newSecret(18)}`,
211
+ client_id_issued_at: Math.floor(now / 1000),
212
+ client_name: name,
213
+ redirect_uris: [...uris],
214
+ grant_types: ["authorization_code", "refresh_token"],
215
+ response_types: ["code"],
216
+ token_endpoint_auth_method: "none",
217
+ created: now,
218
+ };
219
+ store.update((s) => {
220
+ s.clients[client.client_id] = client;
221
+ // A connector that is removed and added again registers again; keep the newest fifty.
222
+ const all = Object.values(s.clients).sort((a, b) => b.created - a.created);
223
+ for (const old of all.slice(MAX_CLIENTS)) delete s.clients[old.client_id];
224
+ }, now);
225
+ const { created, ...out } = client;
226
+ return { status: 201, body: out };
227
+ }
228
+
229
+ // ---- authorization (the part before the owner types the code) ----
230
+
231
+ // Validate an authorization request. Returns { ok: true, req } or { ok: false, redirect?, status,
232
+ // error, description }: an error is sent back to the client's redirect URI only once that URI is
233
+ // known to be the client's own, and shown on the page otherwise (RFC 6749 section 4.1.2.1).
234
+ export function checkAuthorizeRequest(store, q, { resource, env = process.env } = {}) {
235
+ const client = typeof q.client_id === "string" ? store.read().clients[q.client_id] : null;
236
+ if (!client) return { ok: false, status: 400, error: "invalid_client", description: "This client is not registered with this server. Remove the connector and add it again." };
237
+ const registered = client.redirect_uris;
238
+ const redirect = q.redirect_uri ?? (registered.length === 1 ? registered[0] : undefined);
239
+ if (!redirectMatches(registered, redirect, env)) return { ok: false, status: 400, error: "invalid_request", description: "The redirect URI does not match the one this client registered." };
240
+ const back = (error, description) => ({ ok: false, redirect, state: q.state, status: 302, error, description });
241
+ if (q.response_type !== "code") return back("unsupported_response_type", "Only response_type=code is supported.");
242
+ if (typeof q.code_challenge !== "string" || !/^[A-Za-z0-9_-]{43,128}$/.test(q.code_challenge)) return back("invalid_request", "A PKCE code_challenge is required.");
243
+ if (q.code_challenge_method !== "S256") return back("invalid_request", "code_challenge_method must be S256.");
244
+ if (q.resource !== undefined && !sameResource(q.resource, resource)) return back("invalid_target", "This server is the authorization server for its own /mcp only.");
245
+ return { ok: true, req: { client_id: client.client_id, client_name: client.client_name, redirect_uri: redirect, code_challenge: q.code_challenge, state: typeof q.state === "string" ? q.state : undefined, scope: SCOPE, resource } };
246
+ }
247
+
248
+ export function sameResource(asked, resource) {
249
+ const strip = (s) => String(s).replace(/\/+$/, "");
250
+ return strip(asked) === strip(resource);
251
+ }
252
+
253
+ // The owner said yes: mint a code bound to the client, the redirect URI and the challenge.
254
+ export function issueCode(store, req, { now = Date.now() } = {}) {
255
+ const code = newSecret(32);
256
+ store.update((s) => {
257
+ s.codes[sha256(code)] = { client_id: req.client_id, redirect_uri: req.redirect_uri, code_challenge: req.code_challenge, scope: req.scope, resource: req.resource, expires: now + CODE_TTL_MS, used: false, family: null };
258
+ }, now);
259
+ return code;
260
+ }
261
+
262
+ // ---- the token endpoint ----
263
+
264
+ function mintPair(s, { client_id, scope, resource, family }, now) {
265
+ const access = newSecret(32);
266
+ const refresh = newSecret(32);
267
+ s.access[sha256(access)] = { client_id, scope, resource, family, expires: now + ACCESS_TTL_MS };
268
+ s.refresh[sha256(refresh)] = { client_id, scope, resource, family, expires: now + REFRESH_TTL_MS, used: false };
269
+ return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL_MS / 1000), refresh_token: refresh, scope };
270
+ }
271
+
272
+ function revokeFamily(s, family) {
273
+ if (!family) return;
274
+ for (const k of ["access", "refresh"]) for (const [h, r] of Object.entries(s[k])) if (r.family === family) delete s[k][h];
275
+ }
276
+
277
+ const pkceOk = (verifier, challenge, env) => {
278
+ if (FALSIFY("skip-pkce", env)) return true;
279
+ if (typeof verifier !== "string" || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return false;
280
+ const got = Buffer.from(b64url(createHash("sha256").update(verifier).digest()));
281
+ const want = Buffer.from(challenge);
282
+ return got.length === want.length && timingSafeEqual(got, want);
283
+ };
284
+
285
+ export function token(store, form, { resource, now = Date.now(), env = process.env } = {}) {
286
+ const grant = form.grant_type;
287
+ if (grant === "authorization_code") {
288
+ if (typeof form.code !== "string" || typeof form.client_id !== "string") return oauthError(400, "invalid_request", "code and client_id are required.");
289
+ return store.update((s) => {
290
+ const h = sha256(form.code);
291
+ const c = s.codes[h];
292
+ if (!c) return oauthError(400, "invalid_grant", "The authorization code is not valid.");
293
+ if (c.used) {
294
+ // A code presented twice was intercepted or replayed: whatever the first exchange issued goes.
295
+ revokeFamily(s, c.family);
296
+ delete s.codes[h];
297
+ return oauthError(400, "invalid_grant", "The authorization code was already used.");
298
+ }
299
+ if (c.client_id !== form.client_id || !s.clients[c.client_id]) return oauthError(400, "invalid_grant", "The code was issued to a different client.");
300
+ if (!FALSIFY("skip-redirect-check", env) && form.redirect_uri !== c.redirect_uri) return oauthError(400, "invalid_grant", "redirect_uri does not match the authorization request.");
301
+ if (!pkceOk(form.code_verifier, c.code_challenge, env)) return oauthError(400, "invalid_grant", "The PKCE code_verifier does not match the code_challenge.");
302
+ if (form.resource !== undefined && !sameResource(form.resource, resource)) return oauthError(400, "invalid_target", "This server issues tokens for its own /mcp only.");
303
+ c.used = true;
304
+ c.family = newSecret(12);
305
+ return { status: 200, body: mintPair(s, { client_id: c.client_id, scope: c.scope, resource, family: c.family }, now) };
306
+ }, now);
307
+ }
308
+ if (grant === "refresh_token") {
309
+ if (typeof form.refresh_token !== "string" || typeof form.client_id !== "string") return oauthError(400, "invalid_request", "refresh_token and client_id are required.");
310
+ return store.update((s) => {
311
+ const h = sha256(form.refresh_token);
312
+ const r = s.refresh[h];
313
+ if (!r) return oauthError(400, "invalid_grant", "The refresh token is not valid.");
314
+ if (r.client_id !== form.client_id) return oauthError(400, "invalid_grant", "The refresh token was issued to a different client.");
315
+ if (r.used) {
316
+ // A rotated-out refresh token came back: someone holds a copy. End the whole connection.
317
+ revokeFamily(s, r.family);
318
+ return oauthError(400, "invalid_grant", "The refresh token was already used.");
319
+ }
320
+ r.used = true;
321
+ return { status: 200, body: mintPair(s, { client_id: r.client_id, scope: r.scope, resource, family: r.family }, now) };
322
+ }, now);
323
+ }
324
+ return oauthError(400, "unsupported_grant_type", "Only authorization_code and refresh_token are supported.");
325
+ }
326
+
327
+ // RFC 7009: always 200, whether or not the token existed. Revoking either half ends the connection.
328
+ export function revoke(store, form, { now = Date.now(), env = process.env } = {}) {
329
+ if (FALSIFY("accept-revoked", env)) return { status: 200, body: {} };
330
+ if (typeof form.token !== "string") return oauthError(400, "invalid_request", "token is required.");
331
+ store.update((s) => {
332
+ const h = sha256(form.token);
333
+ const hit = s.access[h] ?? s.refresh[h];
334
+ if (hit && (form.client_id === undefined || form.client_id === hit.client_id)) revokeFamily(s, hit.family);
335
+ }, now);
336
+ return { status: 200, body: {} };
337
+ }
338
+
339
+ // Is this bearer token good for this resource now? Returns the token's record, or null.
340
+ export function verifyAccess(store, bearer, { resource, now = Date.now(), env = process.env } = {}) {
341
+ if (typeof bearer !== "string" || bearer.length < 20) return null;
342
+ const s = store.read();
343
+ const r = s.access[sha256(bearer)];
344
+ if (!r) return null;
345
+ if (r.expires <= now && !FALSIFY("accept-expired", env)) return null;
346
+ if (!sameResource(r.resource, resource)) return null;
347
+ return r;
348
+ }
349
+
350
+ // ---- metadata ----
351
+
352
+ export function authServerMetadata(base) {
353
+ return {
354
+ issuer: base,
355
+ authorization_endpoint: `${base}/authorize`,
356
+ token_endpoint: `${base}/token`,
357
+ registration_endpoint: `${base}/register`,
358
+ revocation_endpoint: `${base}/revoke`,
359
+ response_types_supported: ["code"],
360
+ grant_types_supported: ["authorization_code", "refresh_token"],
361
+ code_challenge_methods_supported: ["S256"],
362
+ token_endpoint_auth_methods_supported: ["none"],
363
+ revocation_endpoint_auth_methods_supported: ["none"],
364
+ scopes_supported: [SCOPE, "offline_access"],
365
+ };
366
+ }
367
+
368
+ export function protectedResourceMetadata(base, resource) {
369
+ return { resource, authorization_servers: [base], scopes_supported: [SCOPE], bearer_methods_supported: ["header"], resource_name: "EVDA" };
370
+ }