Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/plugins/plugin-security/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,7 @@
"scripts": {
"build": "tsup --config ../../../tsup.config.ts && node ../../../scripts/check-dts-emitted.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json"
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@objectstack/core": "workspace:*",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,11 @@ const invoiceAuditor: PermissionSet = {
} as any;

const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, memberBaseline, publicReader, invoiceAuditor];
const DENY = RLS_DENY_FILTER.id; // the fail-closed sentinel's marker value
// The fail-closed sentinel's marker value. `RLS_DENY_FILTER` is declared
// `Record<string, unknown>`, so the member arrives as `unknown` and the `rank()`
// substring probe below cannot take it; converted once here rather than at each
// use, and it is a string at run time (`__rls_deny__:00000000-…`).
const DENY = String(RLS_DENY_FILTER.id);

// ── Minimal middleware harness ──────────────────────────────────────────────
// Drives the REAL security CRUD middleware against a single-object schema whose
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-security/src/default-report-sink.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,13 +35,17 @@
* it. `start()` binds `ctx.logger` above both of its early bail-outs (#10706),
* so this holds on a degraded boot too.
*
* ⚠️ There is deliberately no `@ts-expect-error` compile-time pin here.
* `packages/plugins/plugin-security/tsconfig.json` EXCLUDES every `*.test.ts`
* file under `src`
* (TEST_DEBT ledger), so a `@ts-expect-error` in this package evaluates never —
* it is not a weak pin, it is no pin. The compile-time half is carried by
* `pnpm check:optional-error-sink`, which runs on every PR with no `paths:`
* filter and turns RED the moment `warn` goes back to optional on this sink.
* ⚠️ There is no `@ts-expect-error` compile-time pin here, and [#13176] changed
* the REASON rather than the state. Until then `tsconfig.json`'s `**\/*.test.ts`
* exclusion was this package's only word on the subject and no tsc program read
* this file at all, so a directive here would have evaluated NEVER — not a weak
* pin, no pin. The sibling `tsconfig.test.json` compiles this file now, so a
* directive WOULD be live; adding one is a real option and no longer a
* self-deception. It is still not needed for this contract: the compile-time
* half is carried by `pnpm check:optional-error-sink`, which runs on every PR
* with no `paths:` filter and turns RED the moment `warn` goes back to optional
* on this sink — a gate, not a directive, and it covers every sink rather than
* this one call site.
*/

import { describe, expect, it, vi } from 'vitest';
Expand Down
22 changes: 16 additions & 6 deletions packages/plugins/plugin-security/src/explain-engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,14 @@ import { PermissionEvaluator } from './permission-evaluator';
import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine';
import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';

// [#13176] `ExplainDecision.layers` is `ExplainLayer[]` — the z.INPUT shape
// (ADR-0122), in which every `.default([])` member is OPTIONAL before a parse:
// a layer's `contributors`, and a record attribution's `rules`. The engine
// always populates both, which is why the assertions below reach through `?.`
// and not `!`: an absent member arrives at the matcher as `undefined` and the
// expectation still fails loudly, so the check survives the repair. This file
// was outside every tsc program in the package until `tsconfig.test.json`.

const SALES_USER = PermissionSetSchema.parse({
name: 'sales_user',
objects: { leave_request: { allowRead: true, allowCreate: true, readScope: 'unit' } },
Expand DownExpand Up@@ -188,7 +196,9 @@ describe('explainAccess (ADR-0090 D6)', () => {
});
const principal = d.layers.find((l) => l.layer === 'principal')!;
// `contributors` is the z.input type (defaulted, so optional pre-parse) —
// normalize rather than dereference, keeping the test-layer TEST_DEBT flat.
// normalize rather than dereference. Written when this file was outside
// every tsc program and the motive was the TEST_DEBT ledger; it is the
// right shape either way, and [#13176] made it the compiler's business.
const dropped = (principal.contributors ?? []).filter((c) => c.state === 'expired' || c.state === 'deactivated');
expect(dropped).toEqual([
{ kind: 'permission_set', name: 'quarter_close_admin', via: 'held until 2026-06-01T00:00:00Z — expired', state: 'expired' },
Expand DownExpand Up@@ -437,7 +447,7 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => {
);
const tenant = d.layers.find((l) => l.layer === 'tenant_isolation')!;
expect(tenant.record!.outcome).toBe('excluded');
expect(tenant.record!.rules[0]).toMatchObject({ kind: 'tenant_filter', effect: 'excludes' });
expect(tenant.record!.rules?.[0]).toMatchObject({ kind: 'tenant_filter', effect: 'excludes' });
expect(d.record).toMatchObject({ visible: false, decidedBy: 'tenant_isolation' });
});

Expand All@@ -464,7 +474,7 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => {
);
const sharing = d.layers.find((l) => l.layer === 'sharing')!;
expect(sharing.record!.outcome).toBe('admitted');
expect(sharing.record!.rules[0]).toMatchObject({ kind: 'record_share', effect: 'admits', grants: 'read' });
expect(sharing.record!.rules?.[0]).toMatchObject({ kind: 'record_share', effect: 'admits', grants: 'read' });
expect(d.record).toMatchObject({ visible: true, decidedBy: 'sharing' });
});

Expand DownExpand Up@@ -556,7 +566,7 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => {
);
const vama = d.layers.find((l) => l.layer === 'vama_bypass')!;
expect(vama.verdict).toBe('widens');
expect(vama.contributors.map((c) => c.name)).toEqual(['compliance_auditor']);
expect(vama.contributors?.map((c) => c.name)).toEqual(['compliance_auditor']);
expect(d.record).toMatchObject({ visible: true, decidedBy: 'vama_bypass' });
});

Expand DownExpand Up@@ -1097,8 +1107,8 @@ describe('explainAccess — export axis (#3544)', () => {
const crud = d.layers.find((l) => l.layer === 'object_crud');
// The attribution is the point: it names the granting set, so an admin can
// see which grant to remove (or which one is missing).
expect(crud?.contributors.map((c) => c.name)).toContain('exporter');
expect(crud?.contributors.map((c) => c.name)).not.toContain('reader');
expect(crud?.contributors?.map((c) => c.name)).toContain('exporter');
expect(crud?.contributors?.map((c) => c.name)).not.toContain('reader');
});

it('surfaces the readFilter — an export streams the same filtered rows a read does', async () => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,12 @@ const betterAuthSchemaNames = Object.values(PlatformObjects as Record<string, an
.map((v) => v.name as string)
.sort();

const listNames = [...BETTER_AUTH_MANAGED_OBJECTS].sort();
// `string[]`, not the literal union `BETTER_AUTH_MANAGED_OBJECTS` carries: this
// pin compares the list against names read off the shipped schemas, which are
// plain strings, and the comparison runs in BOTH directions. Left as the union,
// `listNames.includes(<a schema name>)` is a type error rather than the
// membership question the pin asks.
const listNames: string[] = [...BETTER_AUTH_MANAGED_OBJECTS].sort();
const setByName = (name: string): any => defaultPermissionSets.find((s) => s.name === name);

describe('BETTER_AUTH_MANAGED_OBJECTS ↔ schemas (drift pin, #3325)', () => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1162,17 +1162,19 @@ describe('reconcilePermissionSetProjection', () => {
// on `ProjectionLogger` (#9754), so no TS caller can build the sink above
// without saying `as unknown as` out loud.
//
// ⚠️ Deliberately NOT pinned here with `@ts-expect-error`. This package's
// tsconfig excludes `**/*.test.ts` (it carries a TEST_DEBT ledger entry in
// scripts/check-type-check-coverage.mjs), so no tsc program compiles this
// file and the directive would evaluate NEVER — a phantom check that reads
// like proof, which is the failure AGENTS.md → "Build & Test" names and
// `pnpm check:type-check-coverage` refuses. The compile-time half of this
// contract is pinned in plugin-email's `outbox-sweep.test.ts`, whose
// package DOES compile its tests (observed: reverting `warn` there turns
// that directive into `error TS2578: Unused '@ts-expect-error' directive`),
// and the type half of BOTH sinks is held by
// `pnpm check:optional-error-sink`.
// ⚠️ Not pinned here with `@ts-expect-error`, and [#13176] moved the reason
// out from under that sentence. It used to be that this package's tsconfig
// excluded `**/*.test.ts` (it carried a TEST_DEBT ledger entry in
// scripts/check-type-check-coverage.mjs), so no tsc program compiled this
// file and a directive here would have evaluated NEVER — a phantom check
// that reads like proof, the failure AGENTS.md → "Build & Test" names. The
// sibling `tsconfig.test.json` compiles this file now and that ledger entry
// is gone, so a directive here would be LIVE. The compile-time half of this
// contract is pinned in plugin-email's `outbox-sweep.test.ts` (observed:
// reverting `warn` there turns that directive into `error TS2578: Unused
// '@ts-expect-error' directive`), and the type half of BOTH sinks is held by
// `pnpm check:optional-error-sink` — so the pin is redundant here rather
// than impossible, which is a different sentence and the true one.
});

it('heals a record that drifted from an EXISTING metadata definition (metadata wins)', async () => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
* because the deny sentinel lives here and nowhere else.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import type { RowLevelSecurityPolicy } from '@objectstack/spec/security';
import { setCelPushdownLimitsModeForTests, __resetPushdownLimitWarnings } from '@objectstack/formula';

Expand DownExpand Up@@ -53,7 +53,11 @@ function compilerWithLogger() {
return { compiler, logger };
}

let consoleWarn: ReturnType<typeof vi.spyOn>;
// [#13176] `ReturnType<typeof vi.spyOn>` instantiates that generic's own type
// parameters, so `mock.calls` came back untyped and every callback over it was
// an implicit `any` — invisible while no tsc program read this file. Naming the
// spied signature types the call records instead of annotating each callback.
let consoleWarn: MockInstance<typeof console.warn>;

beforeEach(() => {
__resetPushdownLimitWarnings();
Expand Down
41 changes: 27 additions & 14 deletions packages/plugins/plugin-security/src/seed-write-refusal.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -592,22 +592,35 @@ describe('a pass that is not refused reports exactly what it did before', () =>
*
* ## Why this reads the declaration instead of using `@ts-expect-error`
*
* Measured, twice, rather than assumed. This package's `tsconfig.json` excludes
* `**\/*.test.ts` and `tsc --noEmit --listFiles` reports ZERO plugin-security
* test files in the program its `typecheck` script runs — so a directive here
* would not be evaluated by that script. `check:type-check-coverage` refuses
* exactly that shape by name ("carries a `@ts-expect-error` directive but no
* tsc program the `typecheck` script runs compiles it … replace the pin with a
* runtime assertion", `PHANTOM_PIN_DEBT` closed to new entries), and it refused
* this file when the pin was first written that way.
* Measured, twice, rather than assumed — AT THE TIME. This package's
* `tsconfig.json` excludes `**\/*.test.ts`, and back then that was the package's
* only word on the subject: `tsc --noEmit --listFiles` reported ZERO
* plugin-security test files in either program the `typecheck` script ran, so a
* directive here would not have been evaluated by any of them.
* `check:type-check-coverage` refuses exactly that shape by name ("carries a
* `@ts-expect-error` directive but no tsc program the `typecheck` script runs
* compiles it … replace the pin with a runtime assertion", `PHANTOM_PIN_DEBT`
* closed to new entries), and it refused this file when the pin was first
* written that way.
*
* So the pin is a runtime assertion over the declaration's own AST. It survives
* removal of `check:optional-error-sink-contract`, which is the point — that
* gate found the hole, but the property belongs to this module.
* [#13176] the sibling `tsconfig.test.json` compiles this file, so that
* measurement no longer holds and a directive here WOULD be evaluated. The pin
* stays a runtime assertion over the declaration's own AST anyway, and now for
* its own reason rather than for the absent compiler: it reads OPTIONALITY off
* the type alias's AST, which is a property no single `@ts-expect-error` call
* site expresses — and it survives removal of
* `check:optional-error-sink-contract`, which is the point. That gate found the
* hole; the property belongs to this module.
*
* ⚠️ Seeded from `__dirname`, not `import.meta.url`: under `module: NodeNext`
* this package resolves as CommonJS, where `import.meta` is TS1470 and pushed
* the shrink-only TEST_DEBT ratchet from 11 to 12.
* ⚠️ Seeded from `__dirname` rather than `import.meta.url`. The original reason
* is spent and is recorded because it is the cost of a hidden layer, not a
* footnote: under the inherited `module: NodeNext` this package resolves as
* CommonJS, `import.meta` is TS1470 there, and that one diagnostic would have
* pushed the shrink-only TEST_DEBT ratchet from 11 to 12 — so a source file was
* shaped around a program whose verdict nothing ever ran. `tsconfig.test.json`
* matches how vitest executes this layer (`module: esnext`), where
* `import.meta` is legal; `__dirname` is left in place because it works under
* both and churning it buys nothing.
*/
describe('SeedLogger guarantees the channel a durability report degrades to', () => {
const CATALOG_SOURCE = resolve(__dirname, 'per-organization-catalog.ts');
Expand Down
91 changes: 91 additions & 0 deletions packages/plugins/plugin-security/tsconfig.test.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
// The TEST-layer type-check program (#13176, adopting the mechanism #5286 set
// for `packages/spec`, #5449 generalised, and #12542 carried to `packages/rest`
// — the closest analogue to this package, see the `paths` note below).
// `tsconfig.json` beside this one stays exactly as it is: it is the BUILD
// config, and `package.json`'s `typecheck` script NAMES this sibling
// (`tsc --noEmit -p tsconfig.test.json`), because a config no script invokes is
// exactly the phantom this whole change is about.
//
// BEFORE THIS FILE, NO tsc PROGRAM COMPILED A SINGLE TEST FILE HERE. Measured
// per program rather than in aggregate, because one combined zero cannot tell
// "excluded" from "the grep was wrong" (`tsc --noEmit --listFiles`, at
// aa16721b6, workspace closure built first):
//
// tsconfig.json 460 files, 0 x `*.test.ts` (the `exclude` below names them)
// tsconfig.scripts.json 310 files, 0 x `*.test.ts` (`include` is `scripts/**/*`)
// THIS FILE 591 files, 89 x `*.test.ts` (the same grep, non-zero)
//
// So `pnpm --filter @objectstack/plugin-security typecheck` exiting 0 was a true
// sentence carrying no information about any of the 89 test files (1625 tests)
// in a package whose suites pin REFUSAL behaviour. AGENTS.md states both halves
// of the rule this file applies: never `exclude` the tests from the config the
// `typecheck` script reads, and "a `@ts-expect-error` in a file no tsc program
// compiles is a phantom check". The cost was not hypothetical here — the
// `__dirname` note in `src/seed-write-refusal.test.ts` records an author
// steering around a diagnostic from a program that never ran.
//
// What differs from the build config, and what deliberately does NOT:
// - MODULE SEMANTICS ONLY, plus `lib`. The tests are written and executed as
// ESM by vitest (esbuild/vite) while this package has no `"type":
// "module"`, so the inherited NodeNext compiles them as CommonJS. Measured
// over the identical file set: NodeNext reports 11 errors, of which 2 are
// the CHECK rather than the code — TS1470 (`import.meta` in a file "which
// will build into CommonJS output", `src/audience-anchor-set-claims.pin.test.ts`,
// a file vitest runs as ESM every day) and TS2550 (`Array.prototype.at`
// against a `lib` older than es2022, `src/permission-set-projection.test.ts`,
// on Node >= 22). Matching vitest is fidelity, not laxity: it is the same
// subtraction `packages/spec` and `packages/rest` made, and it removes the
// pressure that produced the `__dirname` workaround above.
// ⚠️ Nothing is lost on the src side by `moduleResolution: bundler` here:
// every `src/**/*.ts` file is ALSO in the build program above, which keeps
// NodeNext and keeps demanding the `.js` extensions this package ships.
// - ⛔ STRICTNESS IS UNTOUCHED. `strict`, `noUnusedLocals`,
// `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`
// are inherited from the root config, and `types: ["node"]` from
// `tsconfig.json`. Nothing here may loosen a type rule; if a test does not
// compile, that is the finding.
//
// ⚠️ WHAT THIS PROGRAM INHERITS FROM `tsconfig.json`, both load-bearing and
// neither re-declared here (read that file's #11184 comments first):
// - `rootDir: "../.."` (= `packages/`). Already widened there as a
// CONSEQUENCE of the `paths` rule, so the TS6059 pile a narrower root would
// produce does not arise: measured TS6059 x0 over this program.
// - `paths: { "@objectstack/types": ["../../types/src/index.ts"] }`. A child
// that declared its own `paths` would REPLACE this map rather than merge
// into it, silently sending that specifier back to `dist/` — a BUILD
// ARTIFACT — and this program's verdict would then be about the last
// `pnpm build` (`check:type-source-resolution`'s header states why the
// dangerous case is the typecheck that PASSES). This file declares no
// `paths` at all, so the rule stands.
// ⛔ Not extended to the four specifiers `vitest.config.ts` aliases to
// source: PR #12570 measured that route on `packages/rest` and it made the
// test layer WORSE (37 -> 42 errors, the +5 being TS6133 in other packages'
// source billed to a layer that cannot pay it down). The deps this program
// newly reaches through `dist/*.d.ts` are declared instead, in
// `scripts/check-type-source-resolution.mjs`'s registry, on that gate's
// onboarding limb — with the before/after numbers stated in place.
//
// There is NO `test-typecheck-debt.json` beside this config, on purpose — the
// call `packages/metadata-core`, `packages/metadata-fs` and
// `packages/triggers/trigger-record-change` made, and the one this package's
// residue allows. All 9 remaining errors were REPAIRED in the change that added
// this file rather than ledgered (5 in `src/explain-engine.test.ts`, 2 in
// `src/rls-pushdown-limits.test.ts`, 1 each in `src/authz-matrix-gate.test.ts`
// and `src/objects/default-permission-sets.test.ts`), so the whole test layer
// compiles at ZERO. A per-file shrink-only ledger would hold nothing while
// costing this package a `tsx` dependency and two more scripts; a bare
// `tsc --noEmit -p tsconfig.test.json` is the strictly stronger gate at zero
// residue, because ANY error here is red immediately with no ledger to be added
// to. If this package ever acquires residue that cannot be fixed in its own PR,
// that is the moment to wire `scripts/check-test-typecheck.mts` — not before.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2022"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Loading
Loading