From 0199a7d66b916ca3d959e6362611625b14b43141 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 09:04:08 -0300 Subject: [PATCH] refactor(types): export PermissionResolverParams + surface defaultDecision (SD-673) Promotes PermissionResolverParams from a non-exported type alias to an exported interface so consumers can import the resolver-callback contract by name. Currently consumers writing a resolver have to either rely on Config.permissionResolver inference or use Parameters>[0]; both are clumsy and neither is discoverable. One real omission landed alongside the export: the runtime always forwards defaultDecision: boolean to the resolver (computed locally in permissions.js:isAllowed). The non-exported helper omitted it, so resolver authors who wanted to defer to (or branch off) the built-in policy had to re-derive it. Field-presence tightened to match runtime exactly. The runtime spreads every field into the object literal it passes to the resolver, so every key is always present. role and isInternal can hold undefined values; comment, trackedChange, currentUser, and superdoc are coalesced to null and never undefined. Switched from optional (?:) properties to non-optional with explicit | undefined / | null types so the type reflects what the resolver actually receives, not what the consumer might construct. Distinct from CanPerformPermissionParams (consumer input shape): - CanPerformPermissionParams is what consumers pass INTO SuperDoc#canPerformPermission. - PermissionResolverParams is what consumer resolvers RECEIVE, enriched with defaultDecision, currentUser, and superdoc. Also fixes the CanPerformPermissionParams doc that still referred to PermissionResolverParams as 'non-exported.' Consumer fixture (permission-resolver-params-apis.ts) asserts both resolver slots use the named type: - Config.permissionResolver - Modules.comments.permissionResolver Verified: pnpm check:types -> PASS; pnpm check:public:superdoc --skip-build -> PASS (9 ran, 1 skipped, 129.6s). --- packages/superdoc/src/core/types/index.ts | 66 +++++++++++++--- packages/superdoc/src/public/index.ts | 1 + .../superdoc-root-classification.json | 17 ++++- .../snapshots/superdoc-root-classification.md | 9 ++- .../snapshots/superdoc-root-exports.json | 11 ++- .../snapshots/superdoc-root-exports.md | 18 +++-- .../src/all-public-types.ts | 2 + .../src/permission-resolver-params-apis.ts | 76 +++++++++++++++++++ 8 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 tests/consumer-typecheck/src/permission-resolver-params-apis.ts diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 2580c44bb8..41f1fe5029 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1007,16 +1007,57 @@ export interface FindReplaceConfig { // Modules // --------------------------------------------------------------------------- -/** Permission resolver shared by the top-level Config and the comments module. */ -type PermissionResolverParams = { +/** + * Payload passed to a permission resolver callback. SuperDoc invokes + * the resolver when a consumer registers one via + * `Config.permissionResolver` or `Modules.comments.permissionResolver`, + * forwarding the in-flight check so the resolver can decide whether + * to override the built-in policy. + * + * Returning `boolean` from the resolver overrides the default; + * returning `undefined` (or any non-boolean) falls through to + * `defaultDecision`, which the resolver receives so it can mirror or + * branch off the built-in policy without re-deriving it. + * + * `comment` and `trackedChange` are typed as `object | null` because + * consumer comment / tracked-change shapes vary; resolvers that read + * fields on those payloads should narrow before use. + * + * Distinct from `CanPerformPermissionParams`, which is the input + * shape consumers pass _to_ `SuperDoc#canPerformPermission`. That + * input becomes part of this resolver payload after SuperDoc resolves + * `currentUser`, `superdoc`, and `defaultDecision`. + */ +export interface PermissionResolverParams { + /** The permission key being checked (e.g. `'comment.create'`). */ permission: string; - role?: string; - isInternal?: boolean; - comment?: object | null; - trackedChange?: object | null; - currentUser?: User | null; - superdoc?: SuperDoc | null; -}; + /** + * The effective role (consumer-supplied or falling back to + * `Config.role`). The key is always present on the payload; the + * value is `undefined` when `Config.role` was never set. + */ + role: string | undefined; + /** + * The effective internal/external flag (consumer-supplied or + * `Config.isInternal`). The key is always present; the value is + * `undefined` when `Config.isInternal` was never set. + */ + isInternal: boolean | undefined; + /** + * What the built-in policy would return if the resolver does not + * override. Resolvers can return this value to defer to the + * default, or branch off it. + */ + defaultDecision: boolean; + /** The comment object being acted on, if any. Shape is consumer-defined. */ + comment: object | null; + /** The tracked-change payload (as emitted by the editor) being acted on, if any. */ + trackedChange: object | null; + /** The active user performing the action; resolved from `Config.user`. */ + currentUser: User | null; + /** The SuperDoc instance the check ran against. */ + superdoc: SuperDoc | null; +} /** * Input shape for `SuperDoc#canPerformPermission`. All fields are @@ -1026,9 +1067,10 @@ type PermissionResolverParams = { * because the runtime forwards the full payload to the resolver * context, and consumer comment / tracked-change shapes vary; the * named fields below are the ones the method itself reads. Distinct - * from the non-exported `PermissionResolverParams` helper, which - * models the resolver callback payload with resolved `currentUser` - * and `superdoc` context attached. + * from `PermissionResolverParams`, which is the exported resolver + * callback payload SuperDoc passes to configured permission resolvers + * (with resolved `currentUser`, `superdoc`, and `defaultDecision` + * context attached). */ export interface CanPerformPermissionParams { /** The permission key to check (e.g. `'comment.create'`). Required at runtime; omitting returns `false`. */ diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index 8d918d087d..1c8699774c 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -90,6 +90,7 @@ export type { PasswordPromptContext } from '../core/types/index.js'; export type { PasswordPromptHandle } from '../core/types/index.js'; export type { PasswordPromptRenderContext } from '../core/types/index.js'; export type { PasswordPromptResolution } from '../core/types/index.js'; +export type { PermissionResolverParams } from '../core/types/index.js'; export type { ResolvedFindReplaceTexts } from '../core/types/index.js'; export type { ResolvedPasswordPromptTexts } from '../core/types/index.js'; export type { SearchMatch } from '../core/types/index.js'; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json index d3e2fa5a76..383b8236d3 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json @@ -1,14 +1,14 @@ { "generatedAt": "2026-05-19T11:33:50.546Z", "summary": { - "total": 213, + "total": 214, "byBucket": { "legacy-root": 60, "internal-candidate": 8, - "supported-root": 145 + "supported-root": 146 }, "byConfidence": { - "high": 110, + "high": 111, "medium": 101, "low": 2 } @@ -1235,6 +1235,17 @@ "inEsm": false, "inCjs": false }, + { + "name": "PermissionResolverParams", + "bucket": "supported-root", + "rationale": "Payload passed to permission resolver callbacks registered via Config.permissionResolver or Modules.comments.permissionResolver; promoted from a non-exported helper to a named public type so resolver authors can import the contract.", + "confidence": "high", + "source": "config-supported", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, { "name": "PositionHit", "bucket": "legacy-root", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.md b/tests/consumer-typecheck/snapshots/superdoc-root-classification.md index 4179c95eec..32d0f654e3 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-classification.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.md @@ -7,16 +7,16 @@ Input: tests/consumer-typecheck/snapshots/superdoc-root-exports.json (205 names, | Bucket | Count | |---|---| -| supported-root | 145 | +| supported-root | 146 | | legacy-root | 60 | | move-to-subpath | 0 | | internal-candidate | 8 | | NEEDS-REVIEW | 0 | -| **total** | **213** | +| **total** | **214** | -Confidence: high=110, medium=101, needs-review=0. +Confidence: high=111, medium=101, needs-review=0. -## supported-root (145) +## supported-root (146) | Name | Confidence | Source | Rationale | |---|---|---|---| @@ -90,6 +90,7 @@ Confidence: high=110, medium=101, needs-review=0. | `PasswordPromptRenderContext` | medium | password-prompt | PasswordPrompt surface API type. Public. | | `PasswordPromptResolution` | medium | password-prompt | PasswordPrompt surface API type. Public. | | `PermissionParams` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `PermissionResolverParams` | high | config-supported | Payload passed to permission resolver callbacks registered via Config.permissionResolver or Modules.comments.permissionResolver; promoted from a non-exported helper to a named public type so resolver authors can import the contract. | | `ProofingCapabilities` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | | `ProofingCheckRequest` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | | `ProofingCheckResult` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index 5ee156da06..f900446ee9 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-26T10:45:50.855Z", + "generatedAt": "2026-05-26T11:53:56.060Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -125,6 +125,7 @@ "PasswordPromptRenderContext", "PasswordPromptResolution", "PermissionParams", + "PermissionResolverParams", "PositionHit", "PresenceOptions", "PresentationEditor", @@ -344,6 +345,7 @@ "PasswordPromptRenderContext", "PasswordPromptResolution", "PermissionParams", + "PermissionResolverParams", "PositionHit", "PresenceOptions", "PresentationEditor", @@ -545,11 +547,11 @@ } }, "counts": { - "types.import": 213, - "types.require": 213, + "types.import": 214, + "types.require": 214, "import": 41, "require": 41, - "union": 213 + "union": 214 }, "divergences": { "typesImportVsRequire": { @@ -662,6 +664,7 @@ "PasswordPromptRenderContext", "PasswordPromptResolution", "PermissionParams", + "PermissionResolverParams", "PositionHit", "PresenceOptions", "PresentationEditorOptions", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index bd5e282847..49225b0f75 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,17 +1,17 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-05-26T10:45:50.855Z +Generated: 2026-05-26T11:53:56.060Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts | Source | Path | Count | |---|---|---| -| types.import | `./dist/superdoc/src/public/index.d.ts` | 213 | -| types.require | `./dist/superdoc/src/public/index.d.cts` | 213 | +| types.import | `./dist/superdoc/src/public/index.d.ts` | 214 | +| types.require | `./dist/superdoc/src/public/index.d.cts` | 214 | | import | `./dist/superdoc.es.js` | 41 | | require | `./dist/superdoc.cjs` | 41 | -| **union** | | **213** | +| **union** | | **214** | ## Divergences @@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - types.require only (not in types.import): 0 - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 -- typed but no runtime export (phantom risk): 172 +- typed but no runtime export (phantom risk): 173 - runtime export but not typed (silent shadow on root): 0 ### Type-only names (no runtime) @@ -124,6 +124,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `PasswordPromptRenderContext` - `PasswordPromptResolution` - `PermissionParams` +- `PermissionResolverParams` - `PositionHit` - `PresenceOptions` - `PresentationEditorOptions` @@ -228,7 +229,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CommentsPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `CommentsPluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `CommentsType` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Config` | ✓ | ✓ | | | 7 | ✓ | 2 | 1 | 2 | ✓ | +| `Config` | ✓ | ✓ | | | 8 | ✓ | 2 | 1 | 2 | ✓ | | `ContextMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 7 | 0 | 31 | | | `ContextMenuConfig` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ContextMenuContext` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | @@ -253,7 +254,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `EditorState` | ✓ | ✓ | | | 4 | ✓ | 7 | 0 | 1 | ✓ | | `EditorSurface` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorTransactionEvent` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `EditorUpdateEvent` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | +| `EditorUpdateEvent` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `EditorView` | ✓ | ✓ | | | 4 | ✓ | 2 | 0 | 0 | ✓ | | `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 276 | 0 | 8 | | | `ExportDocxParams` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | @@ -293,7 +294,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `LinkPopoverResolver` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ListDefinitionsPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `Measure` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 1 | | -| `Modules` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 0 | | +| `Modules` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 0 | | | `NavigableAddress` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `OpenOptions` | ✓ | ✓ | | | 3 | ✓ | 1 | 0 | 0 | | | `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 35 | 0 | 1 | ✓ | @@ -312,6 +313,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `PasswordPromptRenderContext` | ✓ | ✓ | | | 1 | ✓ | 2 | 0 | 0 | | | `PasswordPromptResolution` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 0 | | | `PermissionParams` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | +| `PermissionResolverParams` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `PositionHit` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PresenceOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PresentationEditor` | ✓ | ✓ | ✓ | ✓ | 3 | | 0 | 0 | 40 | ✓ | diff --git a/tests/consumer-typecheck/src/all-public-types.ts b/tests/consumer-typecheck/src/all-public-types.ts index 38e0938f2e..f53ac804d7 100644 --- a/tests/consumer-typecheck/src/all-public-types.ts +++ b/tests/consumer-typecheck/src/all-public-types.ts @@ -124,6 +124,7 @@ import type { PasswordPromptRenderContext, PasswordPromptResolution, PermissionParams, + PermissionResolverParams, PositionHit, PresenceOptions, PresentationEditorOptions, @@ -305,6 +306,7 @@ const _real_PasswordPromptHandle: AssertNotAny = true; const _real_PasswordPromptRenderContext: AssertNotAny = true; const _real_PasswordPromptResolution: AssertNotAny = true; const _real_PermissionParams: AssertNotAny = true; +const _real_PermissionResolverParams: AssertNotAny = true; const _real_PositionHit: AssertNotAny = true; const _real_PresenceOptions: AssertNotAny = true; const _real_PresentationEditorOptions: AssertNotAny = true; diff --git a/tests/consumer-typecheck/src/permission-resolver-params-apis.ts b/tests/consumer-typecheck/src/permission-resolver-params-apis.ts new file mode 100644 index 0000000000..26d8794a95 --- /dev/null +++ b/tests/consumer-typecheck/src/permission-resolver-params-apis.ts @@ -0,0 +1,76 @@ +/** + * Consumer typecheck: `PermissionResolverParams` export and resolver + * callback contracts. + * + * Locks two things against the emitted `.d.ts`: + * + * 1. `PermissionResolverParams` is reachable as a named public type + * from `superdoc` (was a non-exported helper before this PR). + * Resolver authors can now `import type { PermissionResolverParams } + * from 'superdoc'` and write `(params: PermissionResolverParams)` + * explicitly, instead of relying on inferred shape from + * `Parameters>[0]`. + * + * 2. Both `Config.permissionResolver` and + * `Modules.comments.permissionResolver` use the named type, with + * identical signatures. Drift between the two resolver slots + * would slip past method-coverage fixtures (callbacks are not + * gate-tracked) but fails here on `AssertEqual`. + * + * Promoted as part of the same DX initiative that exported + * `CanPerformPermissionParams` (the consumer input shape). The two + * types overlap on `permission` / `role` / `isInternal` / `comment` / + * `trackedChange` but serve opposite directions of the flow: + * + * - `CanPerformPermissionParams` = what consumers pass INTO + * `SuperDoc#canPerformPermission`. + * - `PermissionResolverParams` = what consumer resolvers RECEIVE, + * enriched with `defaultDecision`, `currentUser`, and `superdoc`. + * + * One real omission landed alongside this export: the runtime always + * forwards `defaultDecision: boolean` to the resolver, but the old + * non-exported helper omitted it. Adding it lets resolvers branch off + * (or defer to) the built-in policy without re-deriving it. Existing + * resolvers that didn't read `defaultDecision` are unaffected. + */ +import type { Config, Modules, PermissionResolverParams } from 'superdoc'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertEqual = Equal extends true ? true : never; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ParamOf any) | undefined> = Parameters>[0]; + +// ─── Config.permissionResolver ────────────────────────────────────── +const _topLevelResolverParamsOk: AssertEqual, PermissionResolverParams> = true; + +// ─── Modules.comments.permissionResolver ──────────────────────────── +// Modules.comments is `false | object`. Narrow to the object form +// before pulling the resolver slot out. +type CommentsModule = Exclude, false>; +const _commentsResolverParamsOk: AssertEqual< + ParamOf, + PermissionResolverParams +> = true; + +// ─── Resolver authors construct payloads against the named type ───── +const sample: PermissionResolverParams = { + permission: 'comment.create', + role: 'editor', + isInternal: true, + defaultDecision: true, + comment: { id: 'c-1' }, + trackedChange: { id: 'tc-1' }, + currentUser: { name: 'A', email: 'a@x.com' }, + superdoc: null, +}; +void sample; + +// Returning `boolean | undefined` matches what isAllowed accepts. +const _resolver: NonNullable = (params) => { + // defaultDecision is now visible on the typed payload. + return params.defaultDecision; +}; +void _resolver; + +void [_topLevelResolverParamsOk, _commentsResolverParamsOk];