mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
092223c259
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.
'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.
The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.
The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.
Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.
The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014
|
|
import { describe, it, expect } from 'vitest'
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from './oauthScopes'
|
|
|
|
// The consent page mirrors the server's scope list by hand, so read the server
|
|
// file and compare. Parsing keeps this a client-only test (no server import).
|
|
function readServerScopes(): string[] {
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8')
|
|
const block = src.slice(src.indexOf('export const SCOPES = {'))
|
|
const body = block.slice(0, block.indexOf('} as const;'))
|
|
return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1])
|
|
}
|
|
|
|
describe('SCOPE_GROUPS', () => {
|
|
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
|
|
const expected = [
|
|
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
|
'places:read', 'places:write',
|
|
'collections:read', 'collections:write',
|
|
'atlas:read', 'atlas:write',
|
|
'packing:read', 'packing:write',
|
|
'todos:read', 'todos:write',
|
|
'budget:read', 'budget:write',
|
|
'reservations:read', 'reservations:write',
|
|
'collab:read', 'collab:write',
|
|
'notifications:read', 'notifications:write',
|
|
'vacay:read', 'vacay:write',
|
|
'geo:read', 'weather:read',
|
|
'journey:read', 'journey:write', 'journey:share',
|
|
'plugins:use',
|
|
]
|
|
for (const scope of expected) {
|
|
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
|
}
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
|
|
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
|
|
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
|
|
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
|
|
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('ALL_SCOPES', () => {
|
|
it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => {
|
|
expect(ALL_SCOPES).toHaveLength(readServerScopes().length)
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => {
|
|
const serverScopes = readServerScopes()
|
|
// Guards the parse itself: a moved file or a reformatted list must fail
|
|
// here rather than quietly compare two empty sets.
|
|
expect(serverScopes.length).toBeGreaterThan(0)
|
|
expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes))
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
|
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
|
|
})
|
|
})
|
|
|
|
describe('SCOPE_GROUP_NAMES', () => {
|
|
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
|
|
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
|
|
const expected = [
|
|
'oauth.scope.group.trips',
|
|
'oauth.scope.group.places',
|
|
'oauth.scope.group.packing',
|
|
'oauth.scope.group.budget',
|
|
]
|
|
for (const g of expected) {
|
|
expect(SCOPE_GROUP_NAMES).toContain(g)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('getScopesByGroup', () => {
|
|
const identity = (key: string) => key
|
|
|
|
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
|
|
const groups = getScopesByGroup(identity)
|
|
// Every scope must appear exactly once across all groups
|
|
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
|
|
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
|
|
for (const scope of ALL_SCOPES) {
|
|
expect(allScopesInGroups).toContain(scope)
|
|
}
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
|
|
const groups = getScopesByGroup(identity)
|
|
for (const items of Object.values(groups)) {
|
|
for (const item of items) {
|
|
expect(item.scope).toBeTruthy()
|
|
expect(item.label).toBeTruthy()
|
|
expect(item.description).toBeTruthy()
|
|
expect(item.group).toBeTruthy()
|
|
}
|
|
}
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
|
|
const groups = getScopesByGroup(identity)
|
|
const tripsGroup = groups['oauth.scope.group.trips']
|
|
expect(tripsGroup).toBeDefined()
|
|
const scopeNames = tripsGroup.map(s => s.scope)
|
|
expect(scopeNames).toContain('trips:read')
|
|
expect(scopeNames).toContain('trips:write')
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
|
|
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
|
|
const groups = getScopesByGroup(t)
|
|
expect(groups['Trips']).toBeDefined()
|
|
expect(groups['oauth.scope.group.trips']).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('client presets', () => {
|
|
// The presets are written as "everything except deletes", so a new scope joins
|
|
// them silently. That is right for a scope over the user's own data and wrong
|
|
// for one that runs third-party code, so the exclusion gets a test.
|
|
it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => {
|
|
expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0)
|
|
for (const scope of PRESET_OPT_IN_ONLY) {
|
|
expect(PRESET_SCOPES_DEFAULT).not.toContain(scope)
|
|
expect(PRESET_SCOPES_READONLY).not.toContain(scope)
|
|
}
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => {
|
|
const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
|
expect(PRESET_SCOPES_DEFAULT).toEqual(expected)
|
|
expect(PRESET_SCOPES_DEFAULT).toContain('trips:write')
|
|
})
|
|
|
|
it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => {
|
|
expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true)
|
|
})
|
|
})
|