diff --git a/.changeset/29297-explain-access-request-record-ids.md b/.changeset/29297-explain-access-request-record-ids.md new file mode 100644 index 00000000000..92cbd37ebe9 --- /dev/null +++ b/.changeset/29297-explain-access-request-record-ids.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +feat(spec): `ExplainAccessRequest` declares `recordIds`, the batch selector the explain wire schema already carries + +Clause-②: yes (widening) +Metadata-protocol: none + +The `security` service contract's `explain(request)` selector, `ExplainAccessRequest`, now declares the optional `recordIds` member. The wire schema `ExplainRequestSchema` already declared it, and both the REST explain route and the reference security service already honour it. A TypeScript caller handing `explain` a batch no longer needs a cast to get past the contract type. + +- **The type is the wire schema's own.** `recordIds` is typed as `ExplainRequest['recordIds']` (a list of ids), so the contract and the schema cannot drift; a type-level test holds the two equal. +- **What it means.** The same record-grained question as `recordId`, for several records of one `(object, operation)` pair: mutually exclusive with `recordId`, at least one and at most `EXPLAIN_BATCH_MAX_RECORD_IDS` ids, answered by `ExplainDecision.records` in the same order. +- **Nothing is required of an implementation.** A security service may ignore `recordIds` and answer only the object-level decision, with no `records`; the REST explain route then asks each id singularly, as it already does. +- **No runtime change.** Only the declared type moves. Every request that compiled before still compiles. diff --git a/packages/spec/src/contracts/security-service.test.ts b/packages/spec/src/contracts/security-service.test.ts index 70d1364d712..614ad94dd22 100644 --- a/packages/spec/src/contracts/security-service.test.ts +++ b/packages/spec/src/contracts/security-service.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, expectTypeOf } from 'vitest'; +import type { z } from 'zod'; import type { ISecurityService, AuthoredRowWriteVerdict, @@ -9,7 +10,36 @@ import type { ControlledByParentWriteOutcome, ControlledByParentWriteDenialLeg, ControlledByParentWriteUnresolvedReason, + ExplainAccessRequest, } from './security-service'; +import type { ExplainRequestSchema } from '../security/explain.zod'; + +// --------------------------------------------------------------------------- +// `ExplainAccessRequest.recordIds` — the service contract's batch selector. +// +// The wire schema (`ExplainRequestSchema`) declared `recordIds` and both the +// REST explain route and the reference security service honour it, while the +// service contract the route calls did not declare it. These pins hold the two +// declarations to one type: the member is OPTIONAL (every singular and +// object-level caller omits it) and EQUAL to the wire schema's input member, so +// a retype on either side turns this file red under `check:test-typecheck`. +// +// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf` +// cannot tell `recordIds?: string[]` from a REQUIRED `recordIds: string[] | +// undefined`, while `{} extends Pick<…>` can. Exported so TS6196 does not +// flag an alias nothing reads. +// --------------------------------------------------------------------------- + +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; + +/** Optional: a request may omit it. A required member turns this alias red. */ +export type ExplainRecordIdsIsOptional = Assert<{} extends Pick ? true : false>; + +/** The wire schema's own input type for the member — neither side may drift. */ +export type ExplainRecordIdsMatchesWire = Assert< + Eq['recordIds']> +>; /** * These tests pin the two things a consumer of the `security` service reasons @@ -563,6 +593,29 @@ describe('Security Service Contract', () => { expect(seen[0]).toEqual({ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' }); }); + it('explain accepts a batch request: recordIds is the wire schema\'s member, typed as a list of ids', async () => { + expectTypeOf() + .toEqualTypeOf['recordIds']>(); + expectTypeOf().toEqualTypeOf(); + + const seen: ExplainAccessRequest[] = []; + const service = makeService({ + explain: async (request) => { seen.push(request); return {} as any; }, + }); + // The literal compiling is the point: before the contract declared the + // member, this call was an excess-property error. + await service.explain( + { object: 'deal', operation: 'read', recordIds: ['r1', 'r2', 'r1'] }, + { userId: 'admin' }, + ); + expect(seen[0]?.recordIds).toEqual(['r1', 'r2', 'r1']); + + // A bare id is the singular selector's job, not a one-element batch. + // @ts-expect-error — `recordIds` is a list, never a lone string. + const bare: ExplainAccessRequest = { object: 'deal', operation: 'read', recordIds: 'r1' }; + expect(bare.object).toBe('deal'); + }); + it('contributeOwnershipFloorAlternates is OPTIONAL — absence is typed, and an alternate names exactly one floor limb', () => { // A security service without the seam still satisfies the contract. // Absence leaves the floor in force, which is the fail-closed direction: diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 776ccc580b5..1f0967b1ebe 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -64,7 +64,7 @@ import type { GetRecordFieldAccessResponse } from '../api/protocol.zod.js'; import type { FilterCondition } from '../data/filter.zod.js'; import type { ExecutionContext } from '../kernel/execution-context.zod.js'; -import type { ExplainDecision, ExplainOperation } from '../security/explain.zod.js'; +import type { EXPLAIN_BATCH_MAX_RECORD_IDS, ExplainDecision, ExplainOperation, ExplainRequest } from '../security/explain.zod.js'; import type { EffectiveObjectPermission, ObjectAccessScope, PermissionSet } from '../security/permission.zod.js'; /** @@ -166,6 +166,25 @@ export interface ExplainAccessRequest { userId?: string; /** Narrow the explanation to a single record (adds record-level layers). */ recordId?: string; + /** + * Batch form of `recordId`: the same record-grained question for several + * records of one `(object, operation)` pair, in one call. The type is the + * wire schema's own member ({@link ExplainRequest}), so the two cannot drift. + * + * - Mutually exclusive with `recordId`. The wire schema refuses a request + * carrying both at the REST explain route, and the reference security + * service refuses it too; an implementation that ignores `recordIds` does + * not. A direct caller of `explain` sends one spelling, never both. + * - At least one and at most {@link EXPLAIN_BATCH_MAX_RECORD_IDS} ids; the + * wire schema refuses an empty or a longer array. + * - Answered by `ExplainDecision.records` in the same order: `records[i]` + * answers `recordIds[i]`, same length, duplicates answered per position. + * - An implementation may ignore it and answer only the object-level + * decision, with no `records`. A caller that needs the per-record verdicts + * then asks each id singularly through `recordId`, as the REST explain + * route does. + */ + recordIds?: ExplainRequest['recordIds']; } /** Filter accepted by the audience-binding suggestion list. */