probe-scope.ts
Language TypeScript6.5 KB144 lines
#!/usr/bin/env tsx/*** Scope probe. Read-only.** The question: POST /todos/query returns only the token holder's to-dos on a* company where several people hold them. Either** (A) the Personal Access Token is hard-scoped to its owner, in which case* the query needs a different credential, or* (B) the endpoint merely defaults to the current user and some parameter* widens it.** This script fires a matrix of request bodies at the live endpoint and reports,* for each, how many rows came back and how many distinct owners appear. The* moment any variant returns a second owner id, the answer is (B) and the* variant that did it is the fix.** pnpm probe:scope** Speculative parameter names are included on purpose. An unknown field is* usually ignored rather than rejected, so the cost of guessing is one request.* (For the company view, GET /todos/company is the answer; see TodosResource.)*/import { clientFromEnv } from '@meridian/ninety-client'import { errorMessage } from '@meridian/shared'interface Variant {label: stringbody: Record<string, unknown>/** What a widened result would prove. */meaning: string}const BASE = {page: 1,pageSize: 100,sort: 'dueDate',order: 'asc',completed: false,archived: false,}async function main(): Promise<void> {const client = clientFromEnv(process.env)const http = client.http// Discover ids to test with: owners and creators seen in a baseline sweep.const baseline = (await http.post('/todos/query', { body: BASE })) as Record<string, unknown>[]const owners = new Set<string>()const creators = new Set<string>()for (const t of baseline) {if (typeof t.userId === 'string') owners.add(t.userId)if (typeof t.createdByUserId === 'string') creators.add(t.createdByUserId)}// Someone who creates work but does not own any of the rows we can see is the// ideal test subject: if a variant surfaces their to-dos, scoping is not hard.const otherUser = [...creators].find((id) => !owners.has(id))console.log('Scope probe')console.log(`baseline rows: ${baseline.length}`)console.log(`distinct owners in baseline: ${owners.size} -> ${[...owners].join(', ') || 'none'}`)console.log(`creators not seen as owners: ${[...creators].filter((c) => !owners.has(c)).join(', ') || 'none'}`)if (!otherUser) {console.log('\nNo second user id available to test with. Ask a colleague for their Ninety user id')console.log('and re-run with: pnpm probe:scope -- --user <id>')}console.log()const teams = await client.teams.list()const variants: Variant[] = [{ label: 'baseline', body: { ...BASE }, meaning: 'control' },{ label: 'isPersonal: false', body: { ...BASE, isPersonal: false }, meaning: 'excludes personal to-dos' },{ label: 'isPersonal: true', body: { ...BASE, isPersonal: true }, meaning: 'personal only' },{ label: 'no completed/archived filters', body: { page: 1, pageSize: 100 }, meaning: 'filters were narrowing it' },...teams.map((t) => ({label: `teamId: ${t.name}`,body: { ...BASE, teamId: t.id },meaning: 'team filter widens beyond the caller',})),// Speculative widening flags. Harmless if ignored.{ label: 'userId: <other user>', body: { ...BASE, userId: otherUser ?? '' }, meaning: 'userId works server-side -> (B)' },{ label: 'userIds: [<other user>]', body: { ...BASE, userIds: otherUser ? [otherUser] : [] }, meaning: 'plural filter exists' },{ label: 'includeAllUsers: true', body: { ...BASE, includeAllUsers: true }, meaning: 'an explicit widen flag' },{ label: 'allUsers: true', body: { ...BASE, allUsers: true }, meaning: 'an explicit widen flag' },{ label: 'showAll: true', body: { ...BASE, showAll: true }, meaning: 'an explicit widen flag' },{ label: 'teamView: true', body: { ...BASE, teamView: true }, meaning: 'team view vs my view' },{ label: 'personalOnly: false', body: { ...BASE, personalOnly: false }, meaning: 'inverse of a my-view default' },].filter((v) => !v.label.includes('other user') || otherUser)const results: { label: string; status: string; rows: number; distinctOwners: number; newOwners: string[] }[] = []for (const v of variants) {try {const rows = (await http.post('/todos/query', { body: v.body })) as Record<string, unknown>[]const seen = new Set<string>()for (const t of rows) if (typeof t.userId === 'string') seen.add(t.userId)const newOwners = [...seen].filter((id) => !owners.has(id))results.push({ label: v.label, status: 'ok', rows: rows.length, distinctOwners: seen.size, newOwners })} catch (err) {const msg = err instanceof Error ? err.message.split('\n')[0]! : String(err)results.push({ label: v.label, status: msg.slice(0, 60), rows: 0, distinctOwners: 0, newOwners: [] })}}console.log('VARIANT'.padEnd(34) + 'ROWS'.padStart(6) + 'OWNERS'.padStart(8) + ' NEW OWNER IDS')console.log('-'.repeat(78))for (const r of results) {const flag = r.newOwners.length ? ' <-- WIDENED' : ''console.log(r.label.padEnd(34) +String(r.rows).padStart(6) +String(r.distinctOwners).padStart(8) +` ${r.newOwners.join(', ')}${flag}` +(r.status !== 'ok' ? ` [${r.status}]` : ''),)}const winner = results.find((r) => r.newOwners.length > 0)console.log('\n' + '='.repeat(78))if (winner) {console.log(`ANSWER: (B). "${winner.label}" returned owners the baseline could not see.`)console.log('The token is not hard-scoped. Add that parameter to toWire() in resources/todos.ts')console.log('and re-run the smoke test; the dashboard is unblocked.')} else {console.log('ANSWER: likely (A). No variant surfaced a second owner.')console.log('')console.log('Before treating this as settled, rule out the boring explanation: confirm in the')console.log('Ninety UI that other people actually have open to-dos right now. A quiet week')console.log('looks identical to a scoping wall from here.')console.log('')console.log('If they do have open to-dos and this probe cannot see them, the PAT is scoped to')console.log('its owner and the dashboard needs either a service account or one token per')console.log('person fanned out at sync time. That is a question for Ninety support, and it is')console.log('the kind of answer that changes the dashboard architecture, so ask before')console.log('building further.')}}main().catch((err) => {console.error('\nProbe failed:', errorMessage(err))process.exit(1)})