|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#6842] Prose may name a shipped permission set for a shape only if the set |
| 5 | + * still HAS that shape. |
| 6 | + * |
| 7 | + * #5491 (PR #6684) removed `member_default`'s plain `'*'` wildcard grant. Three |
| 8 | + * separate pieces of prose named that set as THE worked example of a plain |
| 9 | + * wildcard, and all three went stale without a single gate moving — the |
| 10 | + * `describeHighPrivilegeBits` JSDoc in `packages/spec` (#6696, fixed by PR |
| 11 | + * #6846), and two `it()` titles in this package's `audience-anchors.test.ts` |
| 12 | + * (#6842, fixed alongside this file). Nothing was red at any point, because |
| 13 | + * nothing mechanically related "prose names set X as having shape Y" to what |
| 14 | + * `defaultPermissionSets` actually ships. The drift is silent by construction. |
| 15 | + * |
| 16 | + * This pin supplies that relation, in the #6628 idiom |
| 17 | + * (`packages/spec/src/identity/position-delegatable-enforcer.pin.test.ts`, |
| 18 | + * "the JSDoc may name a lint rule only if that rule exists"). `plugin-security` |
| 19 | + * is the only possible home: the authority is the `defaultPermissionSets` array |
| 20 | + * itself, which `packages/spec` cannot import without inverting the dependency |
| 21 | + * graph — which is exactly why #6696 deliberately shipped without a pin. |
| 22 | + * |
| 23 | + * HOW IT CLOSES. The authority is machine-read (the real array, the real |
| 24 | + * predicates — never a transcription). The prose is machine-read too: shipped |
| 25 | + * set names are exact snake_case tokens, so scanning for them needs no |
| 26 | + * heuristics. What is hand-written is only the CLASSIFICATION — for each set the |
| 27 | + * prose names, whether it is being invoked as a wildcard carrier, and whether it |
| 28 | + * is claimed to stay anchor-bindable. Both directions of that table are closed: |
| 29 | + * |
| 30 | + * - upward — every shipped set name occurring in a watched surface MUST have |
| 31 | + * a row, so newly-named sets cannot slip in unclassified; |
| 32 | + * - downward — every row MUST be named in some watched surface, so rows cannot |
| 33 | + * outlive the prose that motivated them. |
| 34 | + * |
| 35 | + * Replay of the drift it exists for: before #5491 the prose named |
| 36 | + * `member_default` as the wildcard carrier, so its row read `wildcard: true`. |
| 37 | + * #5491 empties `objects['*']` and this file turns red, forcing that PR to flip |
| 38 | + * the row — and to confront the three sentences that flip invalidates. |
| 39 | + * |
| 40 | + * ⛔ LIMIT, stated so it is not mistaken for more. This pins the classification |
| 41 | + * against the array, not the prose against the classification: a row that |
| 42 | + * MISDESCRIBES what its sentences claim is green. That hole cannot be closed |
| 43 | + * without reading intent out of English, and the two negative mentions below |
| 44 | + * show why a proximity heuristic would be worse than nothing — the spec JSDoc |
| 45 | + * names `member_default` two lines from the word "wildcard" precisely to say it |
| 46 | + * has none. What the pin buys is that a #5491-class change can no longer pass |
| 47 | + * in silence: it must now write a false row on purpose. |
| 48 | + * |
| 49 | + * ⛔ Scope: the relation, not the wording. Rewording either surface freely is |
| 50 | + * fine — including dropping a set name entirely, which just retires its row. |
| 51 | + */ |
| 52 | + |
| 53 | +import { readFileSync } from 'node:fs'; |
| 54 | +import { dirname, join, resolve } from 'node:path'; |
| 55 | +import { fileURLToPath } from 'node:url'; |
| 56 | + |
| 57 | +import { describe, it, expect } from 'vitest'; |
| 58 | +import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; |
| 59 | + |
| 60 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 61 | + |
| 62 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 63 | +/** …/packages/plugins/plugin-security/src → repo root */ |
| 64 | +const REPO_ROOT = resolve(HERE, '../../../..'); |
| 65 | +const SPEC_HIGH_PRIVILEGE = join(REPO_ROOT, 'packages', 'spec', 'src', 'security', 'high-privilege.ts'); |
| 66 | +const ANCHOR_TESTS = join(HERE, 'audience-anchors.test.ts'); |
| 67 | + |
| 68 | +/** |
| 69 | + * What a watched surface claims about a set it names. |
| 70 | + * |
| 71 | + * `wildcard` — the set carries a plain `objects['*']` grant. |
| 72 | + * `anchorSafe` — the set may still be bound to the `everyone` anchor. |
| 73 | + * |
| 74 | + * Both are checked against the shipped array and the shipped predicate. There |
| 75 | + * is deliberately NO "just mentioned, do not check" verdict: both watched |
| 76 | + * surfaces are wholly about wildcard and anchor shapes, so for any set they |
| 77 | + * name, both facts are worth holding — and an escape hatch is the one thing |
| 78 | + * that would let this table be silenced instead of maintained. |
| 79 | + */ |
| 80 | +type SetClaim = { wildcard: boolean; anchorSafe: boolean }; |
| 81 | + |
| 82 | +const CLAIMS: Record<string, SetClaim> = { |
| 83 | + // Named by both surfaces for what it does NOT have: no wildcard since #5491, |
| 84 | + // and no `allowExport`, which is why it keeps binding to `everyone`. |
| 85 | + member_default: { wildcard: false, anchorSafe: true }, |
| 86 | + // Named by both surfaces as the shipped set that still carries a plain `'*'`. |
| 87 | + // Its wildcard is read-only, so it is anchor-safe for `everyone` (D9's |
| 88 | + // stricter guest tier refuses it — asserted in `audience-anchors.test.ts`). |
| 89 | + viewer_readonly: { wildcard: true, anchorSafe: true }, |
| 90 | +}; |
| 91 | + |
| 92 | +/** Every permission-set name the platform actually ships. */ |
| 93 | +function shippedSetNames(): string[] { |
| 94 | + return defaultPermissionSets.map((s) => s.name); |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * The `describeHighPrivilegeBits` JSDoc block — the surface #6696 had to repair. |
| 99 | + * Anchored on the declaration rather than on line numbers, which drift. |
| 100 | + */ |
| 101 | +function highPrivilegeDoc(): string { |
| 102 | + const source = readFileSync(SPEC_HIGH_PRIVILEGE, 'utf8'); |
| 103 | + const decl = source.indexOf('export function describeHighPrivilegeBits'); |
| 104 | + expect(decl, '`describeHighPrivilegeBits` moved — re-anchor this pin').toBeGreaterThan(-1); |
| 105 | + const open = source.lastIndexOf('/**', decl); |
| 106 | + const close = source.indexOf('*/', open); |
| 107 | + expect(open, 'no JSDoc block precedes `describeHighPrivilegeBits`').toBeGreaterThan(-1); |
| 108 | + expect(close, 'unterminated JSDoc block').toBeLessThan(decl); |
| 109 | + return source.slice(open, close + 2); |
| 110 | +} |
| 111 | + |
| 112 | +/** The prose surfaces this pin watches, by label. */ |
| 113 | +function watchedSurfaces(): Record<string, string> { |
| 114 | + return { |
| 115 | + 'packages/spec describeHighPrivilegeBits JSDoc': highPrivilegeDoc(), |
| 116 | + 'plugin-security audience-anchors.test.ts': readFileSync(ANCHOR_TESTS, 'utf8'), |
| 117 | + }; |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Shipped set names occurring in `prose`, as exact tokens. The boundaries matter: |
| 122 | + * `organization_admin` is a prefix of `organization_admin_no_bypass`, and only a |
| 123 | + * non-word lookahead keeps the longer name from answering for the shorter one. |
| 124 | + */ |
| 125 | +function namedSets(prose: string, shipped: string[]): string[] { |
| 126 | + return shipped.filter((name) => new RegExp(`(?<![\\w])${name}(?![\\w])`).test(prose)); |
| 127 | +} |
| 128 | + |
| 129 | +/** Rows whose claim the shipped array/predicate does not back. */ |
| 130 | +function unbackedClaims(claims: Record<string, SetClaim>): string[] { |
| 131 | + const findings: string[] = []; |
| 132 | + for (const [name, claim] of Object.entries(claims)) { |
| 133 | + const set = defaultPermissionSets.find((s) => s.name === name); |
| 134 | + if (!set) { |
| 135 | + findings.push(`${name}: no such shipped set`); |
| 136 | + continue; |
| 137 | + } |
| 138 | + const hasWildcard = (set.objects as Record<string, unknown> | undefined)?.['*'] !== undefined; |
| 139 | + if (hasWildcard !== claim.wildcard) { |
| 140 | + findings.push(`${name}: claimed wildcard=${claim.wildcard}, shipped wildcard=${hasWildcard}`); |
| 141 | + } |
| 142 | + const isAnchorSafe = describeAnchorForbiddenBits(set, 'everyone') === null; |
| 143 | + if (isAnchorSafe !== claim.anchorSafe) { |
| 144 | + findings.push(`${name}: claimed anchorSafe=${claim.anchorSafe}, shipped anchorSafe=${isAnchorSafe}`); |
| 145 | + } |
| 146 | + } |
| 147 | + return findings; |
| 148 | +} |
| 149 | + |
| 150 | +/** Set names a surface mentions that the table does not classify. */ |
| 151 | +function unclassifiedNames(prose: string, shipped: string[], claims: Record<string, SetClaim>): string[] { |
| 152 | + return namedSets(prose, shipped).filter((n) => !(n in claims)); |
| 153 | +} |
| 154 | + |
| 155 | +describe('prose names a permission set only for a shape it still has (#6842)', () => { |
| 156 | + it('reads the real shipped array, not a transcription', () => { |
| 157 | + const shipped = shippedSetNames(); |
| 158 | + // A floor, not an exact count — new default sets are expected. Its only job |
| 159 | + // is to fail loudly if the import stops yielding sets, which would turn |
| 160 | + // every check below vacuously green. |
| 161 | + expect(shipped.length).toBeGreaterThanOrEqual(6); |
| 162 | + expect(shipped).toEqual(expect.arrayContaining(['member_default', 'viewer_readonly'])); |
| 163 | + // The #5491 fact this whole file exists for, read off the shipped array. |
| 164 | + const member = defaultPermissionSets.find((s) => s.name === 'member_default')!; |
| 165 | + expect((member.objects as Record<string, unknown>)['*']).toBeUndefined(); |
| 166 | + }); |
| 167 | + |
| 168 | + it('watches surfaces that actually name sets (anti-vacuity)', () => { |
| 169 | + const shipped = shippedSetNames(); |
| 170 | + for (const [label, prose] of Object.entries(watchedSurfaces())) { |
| 171 | + expect(prose.length, `${label}: extracted empty prose`).toBeGreaterThan(200); |
| 172 | + expect(namedSets(prose, shipped), `${label}: names no shipped set`).not.toEqual([]); |
| 173 | + } |
| 174 | + }); |
| 175 | + |
| 176 | + it('would reject a claim the shipped array does not back (self-test)', () => { |
| 177 | + // The pre-#5491 table, verbatim in shape: the prose then named |
| 178 | + // `member_default` as the wildcard carrier, so its row read `wildcard: true`. |
| 179 | + // This is the row #5491 would have had to flip — and the reason it would |
| 180 | + // have had to read the three sentences it invalidated. |
| 181 | + expect(unbackedClaims({ member_default: { wildcard: true, anchorSafe: true } })).toEqual([ |
| 182 | + 'member_default: claimed wildcard=true, shipped wildcard=false', |
| 183 | + ]); |
| 184 | + // A set name that no longer ships at all. |
| 185 | + expect(unbackedClaims({ retired_set: { wildcard: false, anchorSafe: true } })).toEqual([ |
| 186 | + 'retired_set: no such shipped set', |
| 187 | + ]); |
| 188 | + }); |
| 189 | + |
| 190 | + it('would reject a named set the table does not classify (self-test)', () => { |
| 191 | + // Without this direction, "no unclassified names" below could pass simply |
| 192 | + // because a surface stopped naming sets. |
| 193 | + const synthetic = "it('admin_full_access is the wildcard example', () => {});"; |
| 194 | + expect(unclassifiedNames(synthetic, shippedSetNames(), CLAIMS)).toEqual(['admin_full_access']); |
| 195 | + // Prefix discipline: the longer name must not answer for the shorter one. |
| 196 | + expect(unclassifiedNames('see organization_admin_no_bypass', shippedSetNames(), CLAIMS)).toEqual([ |
| 197 | + 'organization_admin_no_bypass', |
| 198 | + ]); |
| 199 | + }); |
| 200 | + |
| 201 | + it('every classified set matches what the platform ships', () => { |
| 202 | + expect(unbackedClaims(CLAIMS)).toEqual([]); |
| 203 | + }); |
| 204 | + |
| 205 | + it('every set the watched prose names is classified (upward closure)', () => { |
| 206 | + const shipped = shippedSetNames(); |
| 207 | + for (const [label, prose] of Object.entries(watchedSurfaces())) { |
| 208 | + expect(unclassifiedNames(prose, shipped, CLAIMS), `${label}: unclassified set name`).toEqual([]); |
| 209 | + } |
| 210 | + }); |
| 211 | + |
| 212 | + it('every classified set is still named by some watched surface (downward closure)', () => { |
| 213 | + const shipped = shippedSetNames(); |
| 214 | + const named = new Set(Object.values(watchedSurfaces()).flatMap((p) => namedSets(p, shipped))); |
| 215 | + expect([...Object.keys(CLAIMS)].filter((n) => !named.has(n))).toEqual([]); |
| 216 | + }); |
| 217 | +}); |
0 commit comments