mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

probe-scope.ts

Language TypeScript6.5 KB144 lines

scripts/probe-scope.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Scope probe. Read-only.
  4. *
  5. * The question: POST /todos/query returns only the token holder's to-dos on a
  6. * company where several people hold them. Either
  7. *
  8. * (A) the Personal Access Token is hard-scoped to its owner, in which case
  9. * the query needs a different credential, or
  10. * (B) the endpoint merely defaults to the current user and some parameter
  11. * widens it.
  12. *
  13. * This script fires a matrix of request bodies at the live endpoint and reports,
  14. * for each, how many rows came back and how many distinct owners appear. The
  15. * moment any variant returns a second owner id, the answer is (B) and the
  16. * variant that did it is the fix.
  17. *
  18. * pnpm probe:scope
  19. *
  20. * Speculative parameter names are included on purpose. An unknown field is
  21. * usually ignored rather than rejected, so the cost of guessing is one request.
  22. * (For the company view, GET /todos/company is the answer; see TodosResource.)
  23. */
  24. import { clientFromEnv } from '@meridian/ninety-client'
  25. import { errorMessage } from '@meridian/shared'
  26. interface Variant {
  27. label: string
  28. body: Record<string, unknown>
  29. /** What a widened result would prove. */
  30. meaning: string
  31. }
  32. const BASE = {
  33. page: 1,
  34. pageSize: 100,
  35. sort: 'dueDate',
  36. order: 'asc',
  37. completed: false,
  38. archived: false,
  39. }
  40. async function main(): Promise<void> {
  41. const client = clientFromEnv(process.env)
  42. const http = client.http
  43. // Discover ids to test with: owners and creators seen in a baseline sweep.
  44. const baseline = (await http.post('/todos/query', { body: BASE })) as Record<string, unknown>[]
  45. const owners = new Set<string>()
  46. const creators = new Set<string>()
  47. for (const t of baseline) {
  48. if (typeof t.userId === 'string') owners.add(t.userId)
  49. if (typeof t.createdByUserId === 'string') creators.add(t.createdByUserId)
  50. }
  51. // Someone who creates work but does not own any of the rows we can see is the
  52. // ideal test subject: if a variant surfaces their to-dos, scoping is not hard.
  53. const otherUser = [...creators].find((id) => !owners.has(id))
  54. console.log('Scope probe')
  55. console.log(`baseline rows: ${baseline.length}`)
  56. console.log(`distinct owners in baseline: ${owners.size} -> ${[...owners].join(', ') || 'none'}`)
  57. console.log(`creators not seen as owners: ${[...creators].filter((c) => !owners.has(c)).join(', ') || 'none'}`)
  58. if (!otherUser) {
  59. console.log('\nNo second user id available to test with. Ask a colleague for their Ninety user id')
  60. console.log('and re-run with: pnpm probe:scope -- --user <id>')
  61. }
  62. console.log()
  63. const teams = await client.teams.list()
  64. const variants: Variant[] = [
  65. { label: 'baseline', body: { ...BASE }, meaning: 'control' },
  66. { label: 'isPersonal: false', body: { ...BASE, isPersonal: false }, meaning: 'excludes personal to-dos' },
  67. { label: 'isPersonal: true', body: { ...BASE, isPersonal: true }, meaning: 'personal only' },
  68. { label: 'no completed/archived filters', body: { page: 1, pageSize: 100 }, meaning: 'filters were narrowing it' },
  69. ...teams.map((t) => ({
  70. label: `teamId: ${t.name}`,
  71. body: { ...BASE, teamId: t.id },
  72. meaning: 'team filter widens beyond the caller',
  73. })),
  74. // Speculative widening flags. Harmless if ignored.
  75. { label: 'userId: <other user>', body: { ...BASE, userId: otherUser ?? '' }, meaning: 'userId works server-side -> (B)' },
  76. { label: 'userIds: [<other user>]', body: { ...BASE, userIds: otherUser ? [otherUser] : [] }, meaning: 'plural filter exists' },
  77. { label: 'includeAllUsers: true', body: { ...BASE, includeAllUsers: true }, meaning: 'an explicit widen flag' },
  78. { label: 'allUsers: true', body: { ...BASE, allUsers: true }, meaning: 'an explicit widen flag' },
  79. { label: 'showAll: true', body: { ...BASE, showAll: true }, meaning: 'an explicit widen flag' },
  80. { label: 'teamView: true', body: { ...BASE, teamView: true }, meaning: 'team view vs my view' },
  81. { label: 'personalOnly: false', body: { ...BASE, personalOnly: false }, meaning: 'inverse of a my-view default' },
  82. ].filter((v) => !v.label.includes('other user') || otherUser)
  83. const results: { label: string; status: string; rows: number; distinctOwners: number; newOwners: string[] }[] = []
  84. for (const v of variants) {
  85. try {
  86. const rows = (await http.post('/todos/query', { body: v.body })) as Record<string, unknown>[]
  87. const seen = new Set<string>()
  88. for (const t of rows) if (typeof t.userId === 'string') seen.add(t.userId)
  89. const newOwners = [...seen].filter((id) => !owners.has(id))
  90. results.push({ label: v.label, status: 'ok', rows: rows.length, distinctOwners: seen.size, newOwners })
  91. } catch (err) {
  92. const msg = err instanceof Error ? err.message.split('\n')[0]! : String(err)
  93. results.push({ label: v.label, status: msg.slice(0, 60), rows: 0, distinctOwners: 0, newOwners: [] })
  94. }
  95. }
  96. console.log('VARIANT'.padEnd(34) + 'ROWS'.padStart(6) + 'OWNERS'.padStart(8) + ' NEW OWNER IDS')
  97. console.log('-'.repeat(78))
  98. for (const r of results) {
  99. const flag = r.newOwners.length ? ' <-- WIDENED' : ''
  100. console.log(
  101. r.label.padEnd(34) +
  102. String(r.rows).padStart(6) +
  103. String(r.distinctOwners).padStart(8) +
  104. ` ${r.newOwners.join(', ')}${flag}` +
  105. (r.status !== 'ok' ? ` [${r.status}]` : ''),
  106. )
  107. }
  108. const winner = results.find((r) => r.newOwners.length > 0)
  109. console.log('\n' + '='.repeat(78))
  110. if (winner) {
  111. console.log(`ANSWER: (B). "${winner.label}" returned owners the baseline could not see.`)
  112. console.log('The token is not hard-scoped. Add that parameter to toWire() in resources/todos.ts')
  113. console.log('and re-run the smoke test; the dashboard is unblocked.')
  114. } else {
  115. console.log('ANSWER: likely (A). No variant surfaced a second owner.')
  116. console.log('')
  117. console.log('Before treating this as settled, rule out the boring explanation: confirm in the')
  118. console.log('Ninety UI that other people actually have open to-dos right now. A quiet week')
  119. console.log('looks identical to a scoping wall from here.')
  120. console.log('')
  121. console.log('If they do have open to-dos and this probe cannot see them, the PAT is scoped to')
  122. console.log('its owner and the dashboard needs either a service account or one token per')
  123. console.log('person fanned out at sync time. That is a question for Ninety support, and it is')
  124. console.log('the kind of answer that changes the dashboard architecture, so ask before')
  125. console.log('building further.')
  126. }
  127. }
  128. main().catch((err) => {
  129. console.error('\nProbe failed:', errorMessage(err))
  130. process.exit(1)
  131. })