Skip to content

Commit ff23956

Browse files
os-zhuangclaude
andcommitted
feat(verify): extract dogfood engine into public @objectstack/verify + CLI
Productize the internal dogfood regression engine as a published library and an `objectstack verify` CLI so third-party and template authors can run the same runtime proofs (auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant) against their own apps. - new public @objectstack/verify: bootStack / deriveCrudCases / runCrudVerification / runRlsProofs, moved+generalized from packages/dogfood/src (tsup ESM+CJS+DTS, publishConfig public) - new `objectstack verify` oclif command (--app/--rls/--multi-tenant/--json); exits 1 on create-failed/read-failed/fidelity-gaps/rls-hole - dogfood now consumes @objectstack/verify; drop 3 redundant auto-verify tests (replaced by an `objectstack verify` CI step over the example apps); keep the hand-written golden regressions (#2018 tz, #1994 RLS, #2004 field fidelity) - fix: the harness used a port-less loopback inject origin (http://localhost) that fails better-auth's default dev trusted-origins (http://localhost:*); use a ported base so dev-admin sign-in passes in a bare node CLI and the test runner alike - add @objectstack/verify to the changeset `fixed` group; changeset for verify+cli Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ea6154e commit ff23956

24 files changed

Lines changed: 449 additions & 249 deletions

.changeset/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@
8080
"@objectstack/account",
8181
"create-objectstack",
8282
"objectstack-vscode",
83-
"@objectstack/connector-openapi"
83+
"@objectstack/connector-openapi",
84+
"@objectstack/verify"
8485
]
8586
],
8687
"linked": [],

.changeset/verify-package.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@objectstack/verify": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
Add `@objectstack/verify` — boot any ObjectStack app in-process and verify it through the real HTTP stack: auto-derived CRUD round-trip fidelity (`runCrudVerification`) plus the cross-owner RLS invariant (`runRlsProofs`, "you can't write what you can't read"). Also adds an `objectstack verify` CLI command that runs these proofs against an app config and exits non-zero on real failures.
7+
8+
Extracted from the internal dogfood regression gate so third-party and template authors can run the same runtime proofs against their own apps. The private `@objectstack/dogfood` package now consumes this library for its golden regression tests.

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,19 @@ jobs:
161161
- name: Boot example apps and exercise real user flows
162162
run: pnpm turbo run test --filter=@objectstack/dogfood
163163

164+
# Replaces the former auto-verify dogfood tests: runs the published
165+
# `objectstack verify` engine over each example app through the CLI —
166+
# auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant.
167+
# Exits non-zero on a real runtime failure, so it gates like the tests did.
168+
- name: Verify example apps via the `objectstack verify` CLI
169+
run: |
170+
pnpm turbo run build --filter=@objectstack/cli
171+
for app in examples/app-crm examples/app-showcase; do
172+
echo "::group::objectstack verify $app --rls"
173+
OS_LOG_LEVEL=error node packages/cli/bin/run.js verify --app "$app/objectstack.config.ts" --rls
174+
echo "::endgroup::"
175+
done
176+
164177
build-core:
165178
name: Build Core
166179
needs: filter

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"@objectstack/service-storage": "workspace:*",
8989
"@objectstack/spec": "workspace:*",
9090
"@objectstack/types": "workspace:*",
91+
"@objectstack/verify": "workspace:*",
9192
"@oclif/core": "^4.11.4",
9293
"bundle-require": "^5.1.0",
9394
"chalk": "^5.6.2",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import { readEnvWithDeprecation } from '@objectstack/types';
6+
import {
7+
bootStack,
8+
runCrudVerification,
9+
formatReport,
10+
runRlsProofs,
11+
formatRlsReport,
12+
type VerifyReport,
13+
type RlsReport,
14+
} from '@objectstack/verify';
15+
import { loadConfig } from '../utils/config.js';
16+
17+
/**
18+
* `objectstack verify` — boot the app in-process and exercise it through the
19+
* real HTTP stack, asserting runtime behavior the static gates can't see:
20+
* - data fidelity: author → write → read → assert, per object/field type
21+
* - authorization (--rls): "you can't write what you can't read" (#1994 class)
22+
*
23+
* Exits non-zero on real failures so it drops straight into CI.
24+
*/
25+
export default class Verify extends Command {
26+
static override description =
27+
'Boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity + the cross-owner RLS invariant)';
28+
29+
static override examples = [
30+
'<%= config.bin %> verify',
31+
'<%= config.bin %> verify --app ./objectstack.config.ts --rls',
32+
'<%= config.bin %> verify --rls --multi-tenant --json',
33+
];
34+
35+
static override flags = {
36+
app: Flags.string({
37+
char: 'a',
38+
description: 'Path to the app config (defaults to ./objectstack.config.{ts,js,mjs})',
39+
}),
40+
rls: Flags.boolean({
41+
description: 'Also run the cross-owner RLS invariant (a fresh member must not write what it cannot read)',
42+
default: false,
43+
}),
44+
'multi-tenant': Flags.boolean({
45+
description: 'Boot org-scoped (register plugin-org-scoping) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)',
46+
default: false,
47+
}),
48+
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
49+
};
50+
51+
async run(): Promise<void> {
52+
const { flags } = await this.parse(Verify);
53+
54+
const { config, absolutePath } = await loadConfig(flags.app);
55+
56+
const multiTenant =
57+
flags['multi-tenant'] ||
58+
String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !==
59+
'false';
60+
61+
const stack = await bootStack(config, { multiTenant });
62+
63+
let crud: VerifyReport;
64+
let rls: RlsReport | undefined;
65+
try {
66+
const adminToken = await stack.signIn();
67+
crud = await runCrudVerification(stack, adminToken, config);
68+
69+
if (flags.rls) {
70+
const memberToken = await stack.signUp('verify-member@objectstack.test');
71+
rls = await runRlsProofs(stack, adminToken, memberToken, config);
72+
}
73+
} finally {
74+
await stack.stop();
75+
}
76+
77+
// Failure contract: a "real" runtime break the app's author must see.
78+
const hardFailures =
79+
crud.summary.createFailed +
80+
crud.summary.readFailed +
81+
crud.summary.fidelityGaps +
82+
(rls?.summary.holes ?? 0);
83+
84+
if (flags.json) {
85+
this.log(JSON.stringify({ app: crud.app, config: absolutePath, multiTenant, crud, rls, hardFailures }, null, 2));
86+
} else {
87+
this.log(formatReport(crud));
88+
if (rls) this.log(formatRlsReport(rls));
89+
this.log('');
90+
this.log(
91+
hardFailures > 0
92+
? chalk.red(`✗ verify FAILED — ${hardFailures} runtime failure(s)`)
93+
: chalk.green('✓ verify passed — no runtime failures'),
94+
);
95+
}
96+
97+
// Force process exit: the in-process stack leaves handles open (http server,
98+
// sqlite-wasm, better-auth timers) that keep the event loop alive after
99+
// stop(), so a bare return would hang. exit() also encodes the CI contract.
100+
this.exit(hardFailures > 0 ? 1 : 0);
101+
}
102+
}

packages/dogfood/README.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,21 @@ sockets, CI-stable). Tests act as a browser client would: sign in, hit
2323

2424
## Layout
2525

26-
- `src/harness.ts``bootDogfoodStack(config)``{ kernel, api, raw, signIn, apiAs, stop }`.
26+
The in-process harness + auto-derived verifiers (`bootStack`, `runCrudVerification`,
27+
`runRlsProofs`) now live in the published **[`@objectstack/verify`](../verify)**
28+
package — point it at any app. This package is the framework's own consumer: it
29+
holds the **hand-written golden tests** that pin specific historical regressions
30+
the generic verifier cannot auto-derive.
31+
32+
- depends on `@objectstack/verify` for `bootStack(config)``{ kernel, api, raw, signIn, signUp, apiAs, stop }`.
2733
- `test/*.dogfood.test.ts` — golden flows. Each should assert on **observable
2834
output** (a number, a bucket label, a row count), not just "no error".
2935

3036
## Adding a golden test
3137

3238
1. Pick a real user flow that a static test can't cover (it spans engine +
3339
service + HTTP, or depends on seeded/written data).
34-
2. `bootDogfoodStack(<appConfig>)`, `signIn()`, drive it via `api()/apiAs()`.
40+
2. `bootStack(<appConfig>)`, `signIn()`, drive it via `api()/apiAs()`.
3541
3. Assert on the concrete result.
3642
4. **Prove it catches the bug**: temporarily revert the relevant fix and confirm
3743
the test goes red. A green-on-the-bug test is not a gate.
@@ -67,7 +73,7 @@ The binding policy — every authorable+live primitive must carry a runtime proo
6773

6874
The capability matrix above proves *data* round-trips. The authorization
6975
dimension proves a record the caller must not touch stays untouched. The
70-
app-agnostic invariant (`src/rls.ts`, `runRlsProofs`):
76+
app-agnostic invariant (`runRlsProofs`, from `@objectstack/verify`):
7177

7278
> **A user who cannot READ a record must not be able to WRITE it.**
7379
@@ -87,9 +93,10 @@ changed. Verdicts: `rls-consistent` (can't read **and** can't write — good),
8793
`rls-hole` (can't read **yet** wrote — the #1994 bug), `member-visible`
8894
(member *can* read it — inconclusive, not a cross-owner scenario).
8995

90-
`auto-verify-rls.dogfood.test.ts` runs this over the example apps, but they boot
91-
**single-tenant**, where every object comes back `member-visible` — so the
92-
by-id-write path is never actually exercised. Two ways to create real isolation:
96+
`objectstack verify <app> --rls` runs this over any app in CI, but the example
97+
apps boot **single-tenant**, where every object comes back `member-visible` — so
98+
the by-id-write path is never actually exercised. Two ways to create real
99+
isolation, both pinned as golden tests here:
93100

94101
### 1. Owner-scoped fixture — `test/rls-fixture.dogfood.test.ts` (hard gate)
95102

@@ -98,11 +105,11 @@ permission set carries `RLS.ownerPolicy('rls_note', 'created_by')`. The predicat
98105
is `created_by = current_user.id` — keyed on the column the engine stamps on
99106
every record and referencing `current_user.id`, **not**
100107
`current_user.organization_id`, so it survives single-tenant policy stripping. A
101-
fresh member genuinely can't read the admin's note. `bootDogfoodStack` takes a
108+
fresh member genuinely can't read the admin's note. `bootStack` takes a
102109
`security:` override so the fixture's permission set is the member's fallback:
103110

104111
```ts
105-
bootDogfoodStack(rlsFixtureStack, { security: rlsFixtureSecurity(ownerScopedMemberSet) })
112+
bootStack(rlsFixtureStack, { security: rlsFixtureSecurity(ownerScopedMemberSet) })
106113
```
107114

108115
- **Green gate** (owner policy on `all` ops) → `rls-consistent`. Safe *only*
@@ -139,7 +146,7 @@ Faithful fix — boot multi-tenant so `@objectstack/plugin-org-scoping` register
139146
before `SecurityPlugin` and the `organization_id` policies apply:
140147

141148
```ts
142-
bootDogfoodStack(crmStack, { multiTenant: true })
149+
bootStack(crmStack, { multiTenant: true })
143150
```
144151

145152
The dev admin is bound to the seeded default org; a fresh `signUp` member is not,

packages/dogfood/package.json

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,18 @@
33
"version": "0.0.1",
44
"private": true,
55
"license": "Apache-2.0",
6-
"description": "Dogfood regression gate — boots real example apps in-process and exercises them through the real HTTP/service stack, catching runtime regressions that static checks (build, unit tests, spec liveness) miss.",
6+
"description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.",
77
"type": "module",
88
"scripts": {
99
"test": "vitest run"
1010
},
1111
"dependencies": {
12-
"@objectstack/core": "workspace:*",
13-
"@objectstack/runtime": "workspace:*",
12+
"@objectstack/verify": "workspace:*",
1413
"@objectstack/objectql": "workspace:*",
1514
"@objectstack/spec": "workspace:*",
16-
"@objectstack/driver-sqlite-wasm": "workspace:*",
17-
"@objectstack/plugin-hono-server": "workspace:*",
18-
"@objectstack/rest": "workspace:*",
19-
"@objectstack/plugin-auth": "workspace:*",
2015
"@objectstack/plugin-security": "workspace:*",
21-
"@objectstack/service-settings": "workspace:*",
22-
"@objectstack/service-analytics": "workspace:*",
2316
"@objectstack/example-crm": "workspace:*",
24-
"@objectstack/example-showcase": "workspace:*",
25-
"@objectstack/plugin-sharing": "workspace:*",
26-
"@objectstack/plugin-org-scoping": "workspace:*"
17+
"@objectstack/example-showcase": "workspace:*"
2718
},
2819
"devDependencies": {
2920
"@types/node": "^25.9.3",

packages/dogfood/test/analytics-timezone.dogfood.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1212
import crmStack from '@objectstack/example-crm';
13-
import { bootDogfoodStack, type DogfoodStack } from '../src/harness.js';
13+
import { bootStack, type VerifyStack } from '@objectstack/verify';
1414

1515
// 03:00 UTC on 2024-03-01 is still 2024-02-29 (19:00) in America/Los_Angeles
1616
// (PST = UTC-8, before DST). So a *day* bucket labels this instant 2024-03-01
@@ -29,11 +29,11 @@ const leadByDay = {
2929
};
3030

3131
describe('dogfood: org timezone drives analytics date bucketing (#1982/#2018)', () => {
32-
let stack: DogfoodStack;
32+
let stack: VerifyStack;
3333
let token: string;
3434

3535
beforeAll(async () => {
36-
stack = await bootDogfoodStack(crmStack);
36+
stack = await bootStack(crmStack);
3737

3838
// Deterministic fixture: N leads pinned to the tz-boundary instant, inserted
3939
// as system so the write path's defaults/validation don't fight the setup.

packages/dogfood/test/auto-verify-rls.dogfood.test.ts

Lines changed: 0 additions & 57 deletions
This file was deleted.

packages/dogfood/test/auto-verify.dogfood.test.ts

Lines changed: 0 additions & 54 deletions
This file was deleted.

0 commit comments

Comments
 (0)