forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscopes.ts
More file actions
90 lines (81 loc) · 2.98 KB
/
Copy pathscopes.ts
File metadata and controls
90 lines (81 loc) · 2.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/**
* The scope vocabulary.
*
* Deliberately three, and deliberately none of them able to move anything. The
* MCP surface exposes no tool that signs or broadcasts, so there is no scope
* that could authorise one — the absence is the design, not an omission to be
* filled in later.
*
* Wording matches the consent screen, because a scope a person cannot read is
* a scope they cannot refuse.
*/
export const SCOPES = ['wallets:read', 'plans:read', 'plans:write'] as const
export type Scope = (typeof SCOPES)[number]
export interface ScopeCopy {
scope: Scope
title: string
detail: string
}
/** What the consent page shows, in the order the design lists it. */
export const SCOPE_COPY: readonly ScopeCopy[] = [
{
scope: 'wallets:read',
title: 'Read your linked wallets',
detail: 'Addresses, balances and chains. Not private keys — there are none here.',
},
{
scope: 'plans:write',
title: 'Build and simulate requests',
detail: 'Pick a wallet, route a swap, decode and dry-run the calls.',
},
{
scope: 'plans:read',
title: 'Send you review links',
detail: 'Each one opens on the review page, on any device.',
},
]
/**
* What is never granted, shown on the consent screen as its own row.
*
* Not a scope. It has no name in the vocabulary above precisely because no
* grant can ever contain it, and giving it a string would be the first step
* toward something requesting it.
*/
export const NEVER_GRANTED = {
title: 'Sign, submit or move anything',
detail: 'Never granted. Ottopus holds no key and cannot sign for you.',
} as const
const KNOWN = new Set<string>(SCOPES)
export function isScope(value: string): value is Scope {
return KNOWN.has(value)
}
/**
* Parse a space-delimited scope string, keeping only what we recognise.
*
* Unknown scopes are dropped rather than rejected, and that is a decision worth
* defending because the alternative looks tidier. RFC 6749 §3.3 permits issuing
* a narrower grant provided the response says so, which ours does — the token
* response carries the granted `scope`, so a client is told exactly what it
* got rather than left to assume.
*
* Rejecting instead would fail a whole connection over one unrecognised word.
* Clients send scopes from cached metadata, from a newer version of a server,
* and the MCP spec itself has clients optionally adding `offline_access`. On a
* surface whose entire value is that an agent can connect, refusing outright
* trades a real failure for a hygiene benefit we already get from the response.
*/
export function parseScopes(raw: string | undefined | null): Scope[] {
if (!raw) return []
const seen = new Set<Scope>()
for (const value of raw.split(/\s+/)) {
if (isScope(value)) seen.add(value)
}
return [...seen]
}
/** Every scope, for a client that asked for none. */
export function defaultScopes(): Scope[] {
return [...SCOPES]
}
export function hasScope(granted: readonly string[], required: Scope): boolean {
return granted.includes(required)
}