@classytic/arc-testkit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ The ecosystem's contributor contract for the `@classytic/arc-*` module fleet: **
10
10
  npm i -D @classytic/arc-testkit mongodb-memory-server
11
11
  ```
12
12
 
13
- `mongoose` (>=9.4.1) is needed by `.`, `/pool`, `/mongo` and `/mongo-commands`; `/conformance` and `/story` need only `vitest` (>=3) and a booted arc app. `mongodb-memory-server` is imported lazily by the default database.
13
+ `@classytic/arc` (>=2.49.0) is an optional peer: only `.` (`bootModuleApp`) imports it; the guards and stories take a booted app from the caller, and a client project that uses only the client drift guard installs neither arc nor mongoose. `mongoose` (>=9.11.4) is needed by `.`, `/pool`, `/mongo` and `/mongo-commands`; `/conformance` and `/story` need only `vitest` (>=4.1.11, tested on 4.1.11 and 5.0.5) and a booted arc app. `mongodb-memory-server` is imported lazily by the default database.
14
14
 
15
15
  ## Usage
16
16
 
@@ -47,6 +47,10 @@ Pass a `TestDatabaseFactory` (from `@classytic/arc/testing`) as `database`, for
47
47
  const t = await bootModuleApp(mods, { database: dbPool.factory });
48
48
  ```
49
49
 
50
+ ## Temp dirs of killed runs
51
+
52
+ Every in-memory server writes a `mongo-mem-*` data dir. `mongoServerProvider` removes it on stop, after a failed start, and synchronously when the process exits without teardown. A run killed outright (task manager, a terminated worker) cannot clean up, so before its first server each process removes `mongo-mem-*` dirs that are older than `TEST_MONGO_SWEEP_HOURS` (default 6; `off` disables) and whose `mongod.lock` names no live process. Set `TEST_MONGO_TMPDIR` (for example `.tmp/mongo`) to keep the data dirs under one project-local dir instead of the system temp dir.
53
+
50
54
  ## API
51
55
 
52
56
  | Export | |
@@ -56,8 +60,8 @@ const t = await bootModuleApp(mods, { database: dbPool.factory });
56
60
  | `BootModuleAppOptions` | `{ replset?, database?, app?, actor?, ports? }` |
57
61
  | `ModulesInput`, `ModuleTestApp` | the modules argument and the boot result types |
58
62
  | `/pool` | `createTestDatabasePool`, `publishPoolServers` |
59
- | `/mongo` | `mongoServerProvider`, `safeWorkerCount`, `safePoolSize`, `awaitIndexBuilds`, `withDbName`, `cacheSizeGB`, `mongodMemoryArgs` |
60
- | `/mongo-commands`, `/mongo-explain` | `countCommands`, `assertConstantCommands`, `summarizeExplain`, `assertIndexServed` |
63
+ | `/mongo` | `mongoServerProvider`, `safeWorkerCount`, `safePoolSize`, `awaitIndexBuilds`, `withDbName`, `cacheSizeGB`, `mongodMemoryArgs`, `sweepStaleMongoMemoryDirs`, `launchMongoMemory`, `stopOnProcessExit` |
64
+ | `/mongo-commands`, `/mongo-explain` | `countCommands`, `assertConstantCommands`, `summarizeExplain`, `assertIndexServed`, `explainCaptured` |
61
65
  | `/conformance` | `check*` / `register*` for list envelope, route schemas, naming, OpenAPI, client drift, collection routes, error contract; `expectClean`, `injectCaller`, `fetchOpenApi`, `routeTable` |
62
66
  | `/story` | `describeStory`, `createCast`, `createStoryClock`, `createStoryAssertions`, `createEventLog`, `expectIdempotent`, `expectSweepsIdempotent` |
63
67
 
@@ -65,7 +69,7 @@ const t = await bootModuleApp(mods, { database: dbPool.factory });
65
69
 
66
70
  ```ts
67
71
  import { assertConstantCommands, countCommands } from '@classytic/arc-testkit/mongo-commands';
68
- import { assertIndexServed } from '@classytic/arc-testkit/mongo-explain';
72
+ import { assertIndexServed, explainCaptured } from '@classytic/arc-testkit/mongo-explain';
69
73
 
70
74
  // What the driver sent (the connection needs `monitorCommands: true` — it refuses otherwise).
71
75
  const { total, byCollection } = await countCommands(conn, () => placeOrder(), { collections: ['orders'] });
@@ -73,6 +77,9 @@ const { total, byCollection } = await countCommands(conn, () => placeOrder(), {
73
77
  await assertConstantCommands(conn, (n) => reader.page(n), [5, 50]);
74
78
  // No COLLSCAN, an index scanned, optionally a named one and a docs-examined ceiling.
75
79
  assertIndexServed(await Model.find(filter).explain('executionStats'), { index: 'org_state', maxDocsExamined: 40 });
80
+ // A query built inside a repository or plugin: capture what it sent, explain exactly that.
81
+ const { explain } = await explainCaptured(conn, () => service.claim(now), { collection: 'timers', commands: ['findAndModify'] });
82
+ assertIndexServed(explain, { index: 'state_due', maxDocsExamined: 6 });
76
83
  ```
77
84
 
78
85
  ## Conformance guards and story kit (opt-in, need `vitest`)
@@ -88,7 +95,21 @@ registerOpenApiGuard({ spec: () => fetchOpenApi(admin), app: () => t.app, anonym
88
95
  ```
89
96
 
90
97
  Every guard is also a `check*` function returning the problems found (empty when green). Exception
91
- lists fail when an entry matches nothing. `@classytic/arc-testkit/story` holds `describeStory`,
98
+ lists fail when an entry matches nothing.
99
+
100
+ The refusal guard calls every mutating route as an anonymous and a signed-in caller with no grant; each call must be
101
+ refused AND leave your state snapshot unchanged. arc validates an action body before the verb's permission, so give
102
+ it a body per verb; exclusions can differ per caller:
103
+
104
+ ```ts
105
+ registerRefusalDoesNoWorkGuard({
106
+ name: 'no role: every write and every verb is refused',
107
+ app: () => t.app, anonymous: () => injectCaller(t.app), unprivileged: () => injectCaller(t.app, noRole), snapshot,
108
+ paramValue: ({ resource }) => (resource === 'ticket' ? seeded.ticketId : '000000000000000000000001'),
109
+ actionBodies: { 'ticket.close': { reason: 'Sample reason' }, 'ticket.reopen': {} },
110
+ exclude: { anonymous: PUBLIC_WRITES, unprivileged: { ...PUBLIC_WRITES, ...SELF_SERVICE } },
111
+ });
112
+ ``` `@classytic/arc-testkit/story` holds `describeStory`,
92
113
  `createCast`, `createStoryClock`, `createStoryAssertions` (outbox / events / feed / mail / audit through
93
114
  readers you inject) and `expectIdempotent`; see the module docs.
94
115
 
@@ -18,12 +18,16 @@ interface AppLike {
18
18
  includeHooks?: boolean;
19
19
  }): string;
20
20
  }
21
- /** `app.arc.registry.getAll()` entries, as far as the schema guard reads them. */
21
+ /** `app.arc.registry.getAll()` entries, as far as the guards read them. */
22
22
  interface RegistryEntryLike {
23
23
  name: string;
24
+ /** The resource's route prefix (`/widgets`); the app may mount it under a further prefix. */
25
+ prefix?: string;
26
+ /** `id: false` mounts the verb on `POST <prefix>/action` instead of `POST <prefix>/:id/action`. */
24
27
  actions?: Array<{
25
28
  name: string;
26
29
  schema?: unknown;
30
+ id?: boolean;
27
31
  }>;
28
32
  customRoutes?: Array<{
29
33
  method: string;
@@ -50,21 +54,21 @@ interface InjectCaller extends Caller {
50
54
  delete(path: string): Promise<Reply>;
51
55
  }
52
56
  /** A `Caller` over `app.inject`, JSON in and out, sending `headers` (an auth token) with every request. */
53
- declare function injectCaller(app: AppLike, headers?: Record<string, string>): InjectCaller;
57
+ export declare function injectCaller(app: AppLike, headers?: Record<string, string>): InjectCaller;
54
58
  interface RouteRow {
55
59
  path: string;
56
60
  methods: string[];
57
61
  }
58
62
  /** The app's route table as `{ path, methods }` rows, read from Fastify's radix-tree dump. */
59
- declare function routeTable(app: Pick<AppLike, "printRoutes">): RouteRow[];
63
+ export declare function routeTable(app: Pick<AppLike, "printRoutes">): RouteRow[];
60
64
  /** `/a/{id}/b`, `/a/:id|:other/b` and `/a/:id/b/` are one shape: `/a/:x/b`. */
61
- declare function shapeOf(path: string): string;
65
+ export declare function shapeOf(path: string): string;
62
66
  /** An exception list: bare keys, or `key -> reason`. */
63
67
  type Exceptions = Iterable<string> | Readonly<Record<string, string>>;
64
68
  /** Fails with the problems listed, one per line; passes when there are none. Use it with the `check*` functions inside a host's own tests. */
65
- declare function expectClean(what: string, problems: readonly string[]): void;
66
- declare const OPENAPI_METHODS: readonly ["get", "put", "post", "delete", "patch"];
67
- interface OpenApiOperation {
69
+ export declare function expectClean(what: string, problems: readonly string[]): void;
70
+ export declare const OPENAPI_METHODS: readonly ["get", "put", "post", "delete", "patch"];
71
+ export interface OpenApiOperation {
68
72
  [extension: string]: unknown;
69
73
  security?: unknown[];
70
74
  requestBody?: {
@@ -78,7 +82,7 @@ interface OpenApiOperation {
78
82
  }>;
79
83
  }>;
80
84
  }
81
- interface OpenApiSchema {
85
+ export interface OpenApiSchema {
82
86
  $ref?: string;
83
87
  type?: string;
84
88
  properties?: Record<string, OpenApiSchema>;
@@ -86,9 +90,10 @@ interface OpenApiSchema {
86
90
  allOf?: OpenApiSchema[];
87
91
  anyOf?: OpenApiSchema[];
88
92
  enum?: unknown[];
93
+ const?: unknown;
89
94
  items?: OpenApiSchema;
90
95
  }
91
- interface OpenApiDocument {
96
+ export interface OpenApiDocument {
92
97
  openapi?: string;
93
98
  security?: unknown[];
94
99
  paths: Record<string, Record<string, OpenApiOperation>>;
@@ -97,13 +102,13 @@ interface OpenApiDocument {
97
102
  };
98
103
  }
99
104
  /** Read the document from the running app (default `/openapi.json`). */
100
- declare function fetchOpenApi(caller: Caller, path?: string): Promise<OpenApiDocument>;
101
- interface PublicOperation {
105
+ export declare function fetchOpenApi(caller: Caller, path?: string): Promise<OpenApiDocument>;
106
+ export interface PublicOperation {
102
107
  /** A path template as it appears in the document, or a test for it. */
103
108
  path: string | RegExp | ((path: string) => boolean);
104
109
  why: string;
105
110
  }
106
- interface OpenApiOptions {
111
+ export interface OpenApiOptions {
107
112
  spec: Source<OpenApiDocument>;
108
113
  /** The booted app: enables the "names every route" check. */
109
114
  app?: Source<AppLike>;
@@ -125,74 +130,84 @@ interface OpenApiOptions {
125
130
  secretPatterns?: readonly RegExp[];
126
131
  }
127
132
  /** The document is OpenAPI 3, prefixed, and has no trailing-slash path. */
128
- declare function checkOpenApiShape(opts: OpenApiOptions): Promise<string[]>;
133
+ export declare function checkOpenApiShape(opts: OpenApiOptions): Promise<string[]>;
129
134
  /** Every route in the table is documented (once, in its bare form). */
130
- declare function checkRoutesDocumented(opts: OpenApiOptions): Promise<string[]>;
135
+ export declare function checkRoutesDocumented(opts: OpenApiOptions): Promise<string[]>;
131
136
  /**
132
137
  * Every operation requires a security scheme (its own `security`, else the document's), except the
133
138
  * public list; a public rule that matches no operation fails as stale.
134
139
  */
135
- declare function checkSecurity(opts: OpenApiOptions): Promise<string[]>;
140
+ export declare function checkSecurity(opts: OpenApiOptions): Promise<string[]>;
136
141
  /** An anonymous GET of every documented, protected, parameterless path is refused with 401. */
137
- declare function checkAnonymousRefused(opts: OpenApiOptions, expected?: number): Promise<string[]>;
142
+ export declare function checkAnonymousRefused(opts: OpenApiOptions, expected?: number): Promise<string[]>;
138
143
  /** The serialised document carries no configuration value. */
139
- declare function checkNoSecrets(opts: OpenApiOptions): Promise<string[]>;
144
+ export declare function checkNoSecrets(opts: OpenApiOptions): Promise<string[]>;
140
145
  /** Registers the OpenAPI guards as vitest tests (the optional ones only when their input is given). */
141
- declare function registerOpenApiGuard(opts: OpenApiOptions): void;
142
- interface ClientRoute {
146
+ export declare function registerOpenApiGuard(opts: OpenApiOptions): void;
147
+ export interface ClientRoute {
143
148
  method: string;
144
149
  path: string;
145
150
  /** Where the client emits it, named in the failure. */
146
151
  source?: string;
147
152
  }
148
153
  /** Spec path templates as matchers: `/api/x/{id}/y` accepts `/api/x/anything/y` and `/api/x/:id/y`. */
149
- declare function specMatcher(spec: OpenApiDocument): (method: string, path: string) => string | undefined;
154
+ export declare function specMatcher(spec: OpenApiDocument): (method: string, path: string) => string | undefined;
150
155
  /** Client routes the document does not have (sorted, deduplicated, with their source). */
151
- declare function missingClientRoutes(spec: OpenApiDocument, routes: readonly ClientRoute[]): string[];
152
- declare function resolveRef(spec: OpenApiDocument, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
156
+ export declare function missingClientRoutes(spec: OpenApiDocument, routes: readonly ClientRoute[]): string[];
157
+ export declare function resolveRef(spec: OpenApiDocument, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
153
158
  /** Every property name a schema offers, through refs and unions. */
154
- declare function propertyNames(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<string>;
155
- interface BodyFieldCheck {
159
+ export declare function propertyNames(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<string>;
160
+ /**
161
+ * Every literal value a schema accepts: `enum` and `const` at its root, through refs and unions,
162
+ * and on its top-level properties (an action router's `action` discriminator).
163
+ */
164
+ export declare function schemaValues(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<unknown>;
165
+ export interface BodyFieldCheck {
156
166
  name?: string;
157
167
  method: string;
158
168
  /** The path template as the document spells it. */
159
169
  path: string;
160
170
  /** Fields the client reads or sends. */
161
171
  fields: readonly string[];
172
+ /**
173
+ * Literal values the client sends in this body (an action router's verbs): each must be an
174
+ * `enum` / `const` value the schema declares (`schemaValues`). Default: none checked.
175
+ */
176
+ values?: readonly unknown[];
162
177
  /** Which body to read. Default: `request`. */
163
178
  in?: "request" | "response";
164
179
  /** Response status to read. Default: 200. */
165
180
  status?: string;
166
181
  }
167
- declare function bodyFieldProblems(spec: OpenApiDocument, check: BodyFieldCheck): string[];
168
- interface ClientDriftOptions {
182
+ export declare function bodyFieldProblems(spec: OpenApiDocument, check: BodyFieldCheck): string[];
183
+ export interface ClientDriftOptions {
169
184
  spec: Source<OpenApiDocument>;
170
185
  routes: Source<readonly ClientRoute[]>;
171
186
  bodyFields?: readonly BodyFieldCheck[];
172
187
  /** At least this many client routes must be supplied (guards against an empty probe). Default: 1. */
173
188
  minRoutes?: number;
174
189
  }
175
- declare function checkClientDrift(opts: ClientDriftOptions): Promise<string[]>;
190
+ export declare function checkClientDrift(opts: ClientDriftOptions): Promise<string[]>;
176
191
  /** Registers the drift guard as vitest tests. */
177
- declare function registerClientDriftGuard(opts: ClientDriftOptions): void;
192
+ export declare function registerClientDriftGuard(opts: ClientDriftOptions): void;
178
193
  /** Write the document to `file` (the server side of the dump: `npm run openapi:dump`). */
179
- declare function dumpOpenApi(spec: OpenApiDocument, file: string): void;
194
+ export declare function dumpOpenApi(spec: OpenApiDocument, file: string): void;
180
195
  /**
181
196
  * Read a dumped document. Throws when the dump is missing or older than any `.ts` file under
182
197
  * `sources`, so a stale document can never pass for the current API. `regenerate` names the command
183
198
  * in the message.
184
199
  */
185
- declare function loadOpenApiDump(file: string, opts?: {
200
+ export declare function loadOpenApiDump(file: string, opts?: {
186
201
  sources?: readonly string[];
187
202
  regenerate?: string;
188
203
  }): OpenApiDocument;
189
- interface CollectionRoute {
204
+ export interface CollectionRoute {
190
205
  /** The bare collection path, e.g. `/api/things`. */
191
206
  path: string;
192
207
  /** The verbs the client sends to it. */
193
208
  methods: readonly string[];
194
209
  }
195
- interface CollectionRoutesOptions {
210
+ export interface CollectionRoutesOptions {
196
211
  app: Source<AppLike>;
197
212
  collections: readonly CollectionRoute[];
198
213
  /** Bare GET paths that must answer 200 for `caller` (and the trailing-slash form is not what is registered). */
@@ -201,11 +216,11 @@ interface CollectionRoutesOptions {
201
216
  paths: readonly string[];
202
217
  };
203
218
  }
204
- declare function checkCollectionRoutes(opts: CollectionRoutesOptions): Promise<string[]>;
219
+ export declare function checkCollectionRoutes(opts: CollectionRoutesOptions): Promise<string[]>;
205
220
  /** Registers the collection route guard as a vitest test. */
206
- declare function registerCollectionRoutesGuard(opts: CollectionRoutesOptions): void;
207
- type ErrorClass = "validation" | "unauthorized" | "forbidden" | "notFound" | "unknownRoute" | "conflict" | "duplicateKey";
208
- interface ErrorProbe {
221
+ export declare function registerCollectionRoutesGuard(opts: CollectionRoutesOptions): void;
222
+ export type ErrorClass = "validation" | "unauthorized" | "forbidden" | "notFound" | "unknownRoute" | "conflict" | "duplicateKey";
223
+ export interface ErrorProbe {
209
224
  /** Send the request that fails this way. */
210
225
  provoke: () => Promise<Reply>;
211
226
  /** Default: 400 validation, 401, 403, 404, 409. */
@@ -217,7 +232,7 @@ interface ErrorProbe {
217
232
  /** Every `details[].code` equals this. Default: `duplicate_key` for duplicateKey. */
218
233
  detailCode?: string;
219
234
  }
220
- interface ErrorContractOptions {
235
+ export interface ErrorContractOptions {
221
236
  probes: Partial<Record<ErrorClass, ErrorProbe | readonly ErrorProbe[]>>;
222
237
  /** An authenticated caller for the built-in `unknownRoute` probe (so a 404 is not a 401). */
223
238
  caller?: Source<Caller>;
@@ -226,25 +241,25 @@ interface ErrorContractOptions {
226
241
  /** Keys allowed on the body. Default: the ErrorContract keys. */
227
242
  allowedKeys?: readonly string[];
228
243
  }
229
- declare const ERROR_CLASSES: readonly ErrorClass[];
244
+ export declare const ERROR_CLASSES: readonly ErrorClass[];
230
245
  /** The reasons a reply is not the expected `ErrorContract` (empty: it is one). */
231
- declare function errorContractProblems(reply: Reply, expected: {
246
+ export declare function errorContractProblems(reply: Reply, expected: {
232
247
  status: number;
233
248
  code?: string | RegExp;
234
249
  details?: "some" | "none";
235
250
  detailCode?: string;
236
251
  }, allowedKeys?: readonly string[]): string[];
237
252
  /** Throws unless the reply is the expected `ErrorContract`; returns the body. */
238
- declare function expectErrorContract(reply: Reply, expected: {
253
+ export declare function expectErrorContract(reply: Reply, expected: {
239
254
  status: number;
240
255
  code?: string | RegExp;
241
256
  details?: "some" | "none";
242
257
  detailCode?: string;
243
258
  }): Record<string, unknown>;
244
- declare function checkErrorContracts(opts: ErrorContractOptions): Promise<string[]>;
259
+ export declare function checkErrorContracts(opts: ErrorContractOptions): Promise<string[]>;
245
260
  /** Registers the error contract guard as vitest tests. */
246
- declare function registerErrorContractGuard(opts: ErrorContractOptions): void;
247
- interface FilterDeclaration {
261
+ export declare function registerErrorContractGuard(opts: ErrorContractOptions): void;
262
+ export interface FilterDeclaration {
248
263
  /** The list route, concrete or a template (`/api/items`). */
249
264
  route: string;
250
265
  /** Filters the screen sends. `op` defaults to `eq`. */
@@ -257,14 +272,14 @@ interface FilterDeclaration {
257
272
  /** Sort fields the screen offers (`-name` and `name` are the same field). */
258
273
  sorts?: readonly string[];
259
274
  }
260
- interface FilterDeclarationsOptions {
275
+ export interface FilterDeclarationsOptions {
261
276
  spec: Source<OpenApiDocument>;
262
277
  declarations: Source<readonly FilterDeclaration[]>;
263
278
  }
264
- declare function checkFilterDeclarations(opts: FilterDeclarationsOptions): Promise<string[]>;
279
+ export declare function checkFilterDeclarations(opts: FilterDeclarationsOptions): Promise<string[]>;
265
280
  /** Registers the declaration check as a vitest test. */
266
- declare function registerFilterDeclarationsGuard(opts: FilterDeclarationsOptions): void;
267
- interface ListEnvelopeOptions {
281
+ export declare function registerFilterDeclarationsGuard(opts: FilterDeclarationsOptions): void;
282
+ export interface ListEnvelopeOptions {
268
283
  app: Source<AppLike>;
269
284
  /** An identity allowed to read every list (an admin). */
270
285
  caller: Source<Caller>;
@@ -286,25 +301,25 @@ interface ListEnvelopeOptions {
286
301
  expectLists?: readonly string[];
287
302
  }
288
303
  /** The reasons a body is not a canonical list (empty: it is one). */
289
- declare function listEnvelopeProblems(body: unknown, methods?: readonly string[], forbidden?: readonly string[]): string[];
290
- interface ListEnvelopeResult {
304
+ export declare function listEnvelopeProblems(body: unknown, methods?: readonly string[], forbidden?: readonly string[]): string[];
305
+ export interface ListEnvelopeResult {
291
306
  problems: string[];
292
307
  /** Paths that answered a list and were checked. */
293
308
  lists: string[];
294
309
  /** Static GET paths considered (after prefix and skip). */
295
310
  probed: string[];
296
311
  }
297
- declare function checkListEnvelopes(opts: ListEnvelopeOptions): Promise<ListEnvelopeResult>;
312
+ export declare function checkListEnvelopes(opts: ListEnvelopeOptions): Promise<ListEnvelopeResult>;
298
313
  /** Registers the list envelope guard as vitest tests. */
299
- declare function registerListEnvelopeGuard(opts: ListEnvelopeOptions): void;
300
- interface PathRule {
314
+ export declare function registerListEnvelopeGuard(opts: ListEnvelopeOptions): void;
315
+ export interface PathRule {
301
316
  name: string;
302
317
  /** Routes whose path matches belong to the family. */
303
318
  match: RegExp;
304
319
  /** The complete `METHOD /path` set the family may have. */
305
320
  allowed: readonly string[];
306
321
  }
307
- interface NamingOptions {
322
+ export interface NamingOptions {
308
323
  app: Source<AppLike>;
309
324
  /** Action verbs must match. Default: camelCase. */
310
325
  verbPattern?: RegExp;
@@ -317,16 +332,43 @@ interface NamingOptions {
317
332
  pathRules?: readonly PathRule[];
318
333
  forbiddenPaths?: readonly RegExp[];
319
334
  }
320
- declare function checkNaming(opts: NamingOptions): Promise<string[]>;
335
+ export declare function checkNaming(opts: NamingOptions): Promise<string[]>;
321
336
  /** Registers the naming guard as vitest tests. */
322
- declare function registerNamingGuard(opts: NamingOptions): void;
323
- interface MutatingRoute {
337
+ export declare function registerNamingGuard(opts: NamingOptions): void;
338
+ export interface MutatingRoute {
324
339
  method: string;
325
340
  /** A concrete path (no `:param`); give `body` when the route needs one to reach its gate. */
326
341
  path: string;
327
342
  body?: unknown;
328
343
  }
329
- interface RefusalOptions {
344
+ /** The two callers the guard sends requests as. */
345
+ export type RefusalCaller = "anonymous" | "unprivileged";
346
+ /** Exclusions per caller: a route one caller may legitimately use is still checked for the other. */
347
+ export interface CallerExceptions {
348
+ readonly anonymous?: Exceptions;
349
+ readonly unprivileged?: Exceptions;
350
+ }
351
+ /** What an `actionBodies` factory is told about the call it builds a body for. */
352
+ export interface ActionBodyContext {
353
+ /** Registry name of the resource (`app.arc.registry`). */
354
+ readonly resource: string;
355
+ /** The action verb. */
356
+ readonly action: string;
357
+ readonly caller: RefusalCaller;
358
+ /** The concrete path the body is sent to. */
359
+ readonly path: string;
360
+ }
361
+ /** A verb's extra body fields (`action` is added by the guard), or a factory called once per call. */
362
+ export type ActionBodySource = Readonly<Record<string, unknown>> | ((ctx: ActionBodyContext) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>);
363
+ /** What a `paramValue` function is told about the derived route. */
364
+ export interface ParamValueContext {
365
+ readonly method: string;
366
+ /** The route-table path, `:params` and all. */
367
+ readonly path: string;
368
+ /** Registry name of the resource whose prefix the path carries (longest match), when the app has arc's registry. */
369
+ readonly resource: string | undefined;
370
+ }
371
+ export interface RefusalOptions {
330
372
  /** The booted app. Required unless `routes` is given. */
331
373
  app?: Source<AppLike>;
332
374
  /** Authenticated, but holding none of the privileges the mutating routes require. */
@@ -343,22 +385,39 @@ interface RefusalOptions {
343
385
  routes?: Source<readonly MutatingRoute[]>;
344
386
  /** Only table routes whose path starts with this. */
345
387
  prefix?: string;
346
- /** `METHOD /path` keys (as the route table spells them) that legitimately accept the caller, with the reason. */
347
- exclude?: Exceptions;
348
- /** Value put in every `:param` of a derived path. Default: a 24-hex id. */
349
- paramValue?: string;
388
+ /**
389
+ * Calls that legitimately accept the caller, with the reason: `METHOD /path` keys (as the route
390
+ * table spells them), or `resource.verb` for one action verb (with `actionBodies`). One list
391
+ * excludes for both callers; `{ anonymous, unprivileged }` excludes per caller. An exclusion that
392
+ * matches nothing is reported as stale.
393
+ */
394
+ exclude?: Exceptions | CallerExceptions;
395
+ /**
396
+ * Value put in every `:param` of a derived path. Default: a 24-hex id. A function picks one per
397
+ * route, e.g. a seeded row's id for a resource whose lookup would otherwise answer 404 before the gate.
398
+ */
399
+ paramValue?: string | ((ctx: ParamValueContext) => string | Promise<string>);
400
+ /**
401
+ * A body per arc action verb, keyed `resource.verb` (registry name and verb). When given, every
402
+ * derived action route (`POST …/:id/action`, `POST …/action`) is called once per verb it mounts,
403
+ * with `{ ...body, action: verb }`. A registered verb with no body (and no exclusion) is reported,
404
+ * and so is a body for a verb that is not checked. Needs `app` (booted by arc).
405
+ */
406
+ actionBodies?: Readonly<Record<string, ActionBodySource>>;
350
407
  /** Statuses that count as a refusal. Default: 401 and 403. */
351
408
  refusedStatuses?: readonly number[];
352
409
  /** Milliseconds to let a late handler finish before the second snapshot. Default: 25. */
353
410
  settleMs?: number;
354
- /** At least this many routes must be checked. Default: 1. */
411
+ /** At least this many calls (routes, or route and verb) must be checked. Default: 1. */
355
412
  minRoutes?: number;
413
+ /** Title of the test `registerRefusalDoesNoWorkGuard` registers. */
414
+ name?: string;
356
415
  }
357
416
  /** Problems found; empty when every refusal answered and left the snapshot unchanged. */
358
- declare function checkRefusalDoesNoWork(opts: RefusalOptions): Promise<string[]>;
359
- /** Registers the refusal guard as a vitest test. */
360
- declare function registerRefusalDoesNoWorkGuard(opts: RefusalOptions): void;
361
- interface RouteSchemaOptions {
417
+ export declare function checkRefusalDoesNoWork(opts: RefusalOptions): Promise<string[]>;
418
+ /** Registers the refusal guard as a vitest test (title: `name`, or the default). */
419
+ export declare function registerRefusalDoesNoWorkGuard(opts: RefusalOptions): void;
420
+ export interface RouteSchemaOptions {
362
421
  app: Source<AppLike>;
363
422
  /** `<resource>.<action>` keys that need no schema (package-mounted actions), with the reason. */
364
423
  actionExceptions?: Exceptions;
@@ -369,12 +428,12 @@ interface RouteSchemaOptions {
369
428
  /** At least this many actions must exist (guards against reading an empty registry). Default: 1. */
370
429
  minActions?: number;
371
430
  }
372
- interface RouteSchemaResult {
431
+ export interface RouteSchemaResult {
373
432
  problems: string[];
374
433
  actions: number;
375
434
  routes: number;
376
435
  }
377
- declare function checkRouteSchemas(opts: RouteSchemaOptions): Promise<RouteSchemaResult>;
436
+ export declare function checkRouteSchemas(opts: RouteSchemaOptions): Promise<RouteSchemaResult>;
378
437
  /** Registers the schema guard as vitest tests. */
379
- declare function registerRouteSchemaGuard(opts: RouteSchemaOptions): void;
380
- export { type AppLike, BodyFieldCheck, type Caller, ClientDriftOptions, ClientRoute, CollectionRoute, CollectionRoutesOptions, ERROR_CLASSES, ErrorClass, ErrorContractOptions, ErrorProbe, type Exceptions, FilterDeclaration, FilterDeclarationsOptions, type InjectCaller, ListEnvelopeOptions, ListEnvelopeResult, MutatingRoute, NamingOptions, OPENAPI_METHODS, OpenApiDocument, OpenApiOperation, OpenApiOptions, OpenApiSchema, PathRule, PublicOperation, RefusalOptions, type RegistryEntryLike, type Reply, type RouteRow, RouteSchemaOptions, RouteSchemaResult, type Source, bodyFieldProblems, checkAnonymousRefused, checkClientDrift, checkCollectionRoutes, checkErrorContracts, checkFilterDeclarations, checkListEnvelopes, checkNaming, checkNoSecrets, checkOpenApiShape, checkRefusalDoesNoWork, checkRouteSchemas, checkRoutesDocumented, checkSecurity, dumpOpenApi, errorContractProblems, expectClean, expectErrorContract, fetchOpenApi, injectCaller, listEnvelopeProblems, loadOpenApiDump, missingClientRoutes, propertyNames, registerClientDriftGuard, registerCollectionRoutesGuard, registerErrorContractGuard, registerFilterDeclarationsGuard, registerListEnvelopeGuard, registerNamingGuard, registerOpenApiGuard, registerRefusalDoesNoWorkGuard, registerRouteSchemaGuard, resolveRef, routeTable, shapeOf, specMatcher };
438
+ export declare function registerRouteSchemaGuard(opts: RouteSchemaOptions): void;
439
+ export type { AppLike, Caller, Exceptions, InjectCaller, RegistryEntryLike, Reply, RouteRow, Source };