import { SmrtObject } from '@happyvertical/smrt-core'; import { type FieldPolicyOptions, type FieldPolicyScopeType, type FieldPolicyVisibility } from '../types.js'; /** * Sparse, layered field policy override (epic #2045, issue #2047). * * One row personalizes a subset of `{defaultValue, visibility, help, label, * displayOrder, locked}` for a single `(objectRef, fieldName)` at one scope * tier. A NULL column means "inherit from the lower layer" (code seed → app → * tenant → user), so resetting a customization is a row DELETE — later * lower-layer changes then flow through (sparse-delta rationale, #1770). * * Rows are validated at write time against the live `ObjectRegistry` (the * manifest is the definition registry): unknown objects/fields are rejected, * defaults are type-checked, and the security rail (`sensitive` / * `readPermission` / `transient`) refuses stored defaults outright. */ export declare class FieldPolicy extends SmrtObject { /** Qualified class name of the target object (`@package/name:ClassName`). */ objectRef: string; /** Field name on the target object (validated against the registry). */ fieldName: string; /** Scope tier this row belongs to ('app' | 'tenant' | 'user'). */ scopeType: FieldPolicyScopeType; /** Owning tenant for tenant-scope rows; NULL otherwise (native UUID on PG). */ tenantId: string | null; /** Owning user for user-scope rows; NULL otherwise. */ userId: string | null; /** * Computed uniqueness key (`userId ?? tenantId ?? '__app__'`), set in * `save()`. Used ONLY by `conflictColumns` so the unique index stays total * while `tenantId`/`userId` are nullable — mirrors `PromptOverride.context`. * Never read it for scoping logic; `scopeType` + the typed columns own that. */ scopeKey: string; /** JSON-encoded default value; NULL = inherit. JSON `null` = "default to null". */ defaultValue: string | null; /** Visibility override ('basic' | 'advanced' | 'hidden'); NULL = inherit. */ visibility: FieldPolicyVisibility | null; /** Help text override; NULL = inherit (code seed: field description). */ help: string | null; /** Label override; NULL = inherit (consumers derive from the field name). */ label: string | null; /** * Sort-order override; NULL = inherit (code seed: `ui.order`). Named * `displayOrder` because a column literally named `order` is an SQL keyword * the runtime INSERT path does not quote; resolved output exposes `order`. */ displayOrder: number | null; /** * Org lock (app/tenant rows only): when the effective lock is true, the * user tier may not override this field. NULL = inherit (code seed: * `ui.locked`); org rows may set `false` to explicitly unlock. */ locked: boolean | null; /** Audit attribution for #2050 ("who changed what"); not validated. */ updatedBy: string | null; constructor(options?: FieldPolicyOptions); /** Parse the stored JSON default. `undefined` = no stored default (inherit). */ getDefaultValue(): unknown; /** Serialize a default value; `undefined` clears the override (inherit). */ setDefaultValue(value: unknown): void; save(): Promise; private saveAfterIdentityChange; private saveAfterIdentityChangeInTransaction; private saveAfterIdentityChangeWithDeferredDelete; delete(): Promise; private validateFieldPolicy; /** * Effective org-tier (code → app → tenant hierarchy) policy for this row's * field, computed by the RESOLVER so write-time checks share the one * precedence implementation — including ancestor-tenant cascades via the * default hierarchy loader. The chain tenant is the row's own tenant for * tenant-scope rows, the ambient context tenant for user-scope rows, and * none for app-scope rows (their only lower layer is the code seed). * * During an update, the persisted row is excluded from the lower-layer * projection. That makes a clear-default + required-field demotion validate * the post-write policy rather than accidentally counting the row being * replaced. */ private resolveOrgTierFieldPolicy; /** * Exactly-one-owner scope shape: app rows carry neither id, tenant rows * carry only `tenantId`, user rows carry only `userId` (the user tier is * keyed by user alone so preferences follow the user across tenants). */ private validateScopeConsistency; /** `userId ?? tenantId ?? '__app__'` — the `conflictColumns` scope key. */ private computeScopeKey; /** * Derive a scope-owner column the transport could not carry (#2047). * * Core's mass-assignment guard treats `tenantId` as server-managed and * strips it from EVERY generated create/update body, and `FieldPolicy` is * deliberately not `@TenantScoped`, so the tenancy interceptor never * repopulates it. A `POST {scopeType:'tenant', tenantId}` therefore always * reached `validateScopeConsistency` with `tenantId === null`, making the * org tier write-dead over every generated surface — the model, as the * single validation authority for these rows, fills it in instead. * * This grants nothing: `assertScopeOwnedByAmbientContext` already requires * a non-bypass caller's tenant/user rows to name exactly the ambient * tenant/user, so the derived value is the ONLY value that could ever have * been accepted. An explicit value is never overwritten (a super-admin * bypass caller keeps naming other scopes), and with neither tenancy nor * authenticated-session context nothing is derived — scope-shape validation * rejects the row as before. * Consequence: a bypass caller writing ANOTHER tenant's row must pass * `tenantId` through a server-side model call, because the generated routes * still strip it. */ private attributeScopeToAmbientContext; /** * Fail-closed write boundary against the ambient tenant context, applied to * the NEW scope on save and to the PERSISTED scope on save/delete of an * existing row. * * With NO ambient identity at all (neither tenant ALS nor authenticated * session-permission ALS), only app-scope rows are accepted: tenant- and * user-scope rows are unattributable without a context, so they are * rejected outright rather than allowed by default. Inside a non-bypass * context, a caller may only touch rows for its own tenant, and user rows * only for its own user id — a context that carries NO user id may not * touch the user tier at all. * App-wide rows then require super-admin bypass (or a context-less/system * caller). * * Package rule: a missing identity component DENIES, it never skips. */ private assertScopeOwnedByAmbientContext; private validateTenantContextBoundary; /** * Permission boundary for policy mutations. Scope ownership is checked * separately (and first) because permission never grants cross-tenant or * cross-user access. Existing rows are checked against their persisted * scope before an identity-changing save can mutate or delete them. */ private assertScopePermission; private validateDefaultAgainstSecurityRail; private parseDefaultValueOrThrow; /** * The persisted row this save/delete would replace, looked up by primary * key and — when that misses — by the NATURAL key. * * The natural-key fallback is load-bearing for authorization: every * generated create arrives with a freshly minted UUID, so a primary-key * lookup always misses, yet the `conflictColumns` upsert still overwrites * whatever row already occupies `(objectRef, fieldName, scopeType, * scopeKey)`. Authorizing on the primary key alone therefore skipped the * persisted-scope guard on exactly the path that can replace an existing * row's contents. */ private getPersistedIdentity; private getPersistedRowById; /** * The row occupying this row's `conflictColumns` tuple, if any. Column * names are the physical snake_case ones: this reads the driver directly * rather than through a collection (the model has no collection handle). */ private getPersistedRowByNaturalKey; private normalizeDefaultValueForPersistence; } //# sourceMappingURL=FieldPolicy.d.ts.map