From 0f42108b13b6579e0d49aee16b50c3dde9940693 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 6 May 2026 06:28:17 +0000 Subject: [PATCH 1/2] feat(node-type-registry): add DataLimitCounter, DataFeatureFlag, AuthzAppMembership - DataLimitCounter: attaches limit-tracking triggers (increment on INSERT, decrement on DELETE) - DataFeatureFlag: gates tables behind cap-based feature flags (max=0/1) - AuthzAppMembership: app-level membership check (hardcoded membership_type=1), replaces AuthzMembership for clarity --- .../src/authz/authz-app-membership.ts | 48 +++++++++++++++++++ .../node-type-registry/src/authz/index.ts | 1 + .../src/data/data-feature-flag.ts | 36 ++++++++++++++ .../src/data/data-limit-counter.ts | 46 ++++++++++++++++++ packages/node-type-registry/src/data/index.ts | 2 + 5 files changed, 133 insertions(+) create mode 100644 packages/node-type-registry/src/authz/authz-app-membership.ts create mode 100644 packages/node-type-registry/src/data/data-feature-flag.ts create mode 100644 packages/node-type-registry/src/data/data-limit-counter.ts diff --git a/packages/node-type-registry/src/authz/authz-app-membership.ts b/packages/node-type-registry/src/authz/authz-app-membership.ts new file mode 100644 index 000000000..d2773fd24 --- /dev/null +++ b/packages/node-type-registry/src/authz/authz-app-membership.ts @@ -0,0 +1,48 @@ +import type { NodeTypeDefinition } from '../types'; + +export const AuthzAppMembership: NodeTypeDefinition = { + name: 'AuthzAppMembership', + slug: 'authz_app_membership_check', + category: 'authz', + display_name: 'App Membership Check', + description: + 'App-level membership check (membership_type=1). Verifies the user has app membership (optionally with specific permission) without binding to any entity from the row. Uses EXISTS subquery against SPRT table. Replaces AuthzMembership for clarity.', + parameter_schema: { + type: 'object', + properties: { + membership_type: { + type: ['integer', 'string'], + description: + 'Scope: 1=app, 2=org, 3+=dynamic entity types (or string name resolved via membership_types_module)', + }, + entity_type: { + type: 'string', + description: + "Entity type prefix (e.g. 'channel', 'department'). Resolved to membership_type integer via memberships_module lookup. Use instead of membership_type for readability.", + }, + permission: { + type: 'string', + description: + 'Single permission name to check (resolved to bitstring mask)', + }, + permissions: { + type: 'array', + items: { + type: 'string', + }, + description: + 'Multiple permission names to check (ORed together into mask)', + }, + is_admin: { + type: 'boolean', + description: 'If true, require is_admin flag', + }, + is_owner: { + type: 'boolean', + description: 'If true, require is_owner flag', + }, + }, + required: [], + }, + tags: ['membership', 'authz'], +}; diff --git a/packages/node-type-registry/src/authz/index.ts b/packages/node-type-registry/src/authz/index.ts index b3324b244..446831b78 100644 --- a/packages/node-type-registry/src/authz/index.ts +++ b/packages/node-type-registry/src/authz/index.ts @@ -1,4 +1,5 @@ export { AuthzAllowAll } from './authz-allow-all'; +export { AuthzAppMembership } from './authz-app-membership'; export { AuthzComposite } from './authz-composite'; export { AuthzDenyAll } from './authz-deny-all'; export { AuthzDirectOwner } from './authz-direct-owner'; diff --git a/packages/node-type-registry/src/data/data-feature-flag.ts b/packages/node-type-registry/src/data/data-feature-flag.ts new file mode 100644 index 000000000..bd31de956 --- /dev/null +++ b/packages/node-type-registry/src/data/data-feature-flag.ts @@ -0,0 +1,36 @@ +import type { NodeTypeDefinition } from '../types'; + +export const DataFeatureFlag: NodeTypeDefinition = { + name: 'DataFeatureFlag', + slug: 'data_feature_flag', + category: 'data', + display_name: 'Feature Flag', + description: + 'Gates a table behind a feature flag backed by the cap tables. Attaches a BEFORE INSERT trigger that checks whether the named feature cap value is > 0. Features are modeled as caps with max=0 (disabled) or max=1 (enabled) in limit_caps / limit_caps_defaults tables. Resolution: COALESCE(per-entity cap, scope default, 0).', + parameter_schema: { + type: 'object', + properties: { + feature_name: { + type: 'string', + description: + 'Cap name representing this feature (must match a limit_caps_defaults entry with max=0 or max=1)', + }, + scope: { + type: 'string', + enum: ['app', 'org'], + description: + 'Feature scope: "app" (membership_type=1, app-level caps) or "org" (membership_type=2, per-entity caps)', + default: 'app', + }, + entity_field: { + type: 'string', + format: 'column-ref', + description: + 'Column on the target table that holds the entity id for per-entity cap lookups (only used for org scope)', + default: 'entity_id', + }, + }, + required: ['feature_name'], + }, + tags: ['limits', 'triggers', 'feature-flags', 'billing', 'caps'], +}; diff --git a/packages/node-type-registry/src/data/data-limit-counter.ts b/packages/node-type-registry/src/data/data-limit-counter.ts new file mode 100644 index 000000000..9ee48a058 --- /dev/null +++ b/packages/node-type-registry/src/data/data-limit-counter.ts @@ -0,0 +1,46 @@ +import type { NodeTypeDefinition } from '../types'; + +export const DataLimitCounter: NodeTypeDefinition = { + name: 'DataLimitCounter', + slug: 'data_limit_counter', + category: 'data', + display_name: 'Limit Counter', + description: + 'Declaratively attaches limit-tracking triggers to a table. On INSERT the named limit is incremented; on DELETE it is decremented. Requires a provisioned limits_module for the target scope.', + parameter_schema: { + type: 'object', + properties: { + limit_name: { + type: 'string', + description: + 'Name of the limit to track (must match a default_limits entry, e.g. "projects", "members")', + }, + scope: { + type: 'string', + enum: ['app', 'org'], + description: + 'Limit scope: "app" (membership_type=1, user-level) or "org" (membership_type=2, entity-level)', + default: 'app', + }, + actor_field: { + type: 'string', + format: 'column-ref', + description: + 'Column on the target table that holds the actor or entity id used for limit lookup', + default: 'owner_id', + }, + events: { + type: 'array', + items: { + type: 'string', + enum: ['INSERT', 'DELETE', 'UPDATE'], + }, + description: + 'Which DML events to attach triggers for', + default: ['INSERT', 'DELETE'], + }, + }, + required: ['limit_name'], + }, + tags: ['limits', 'triggers', 'billing'], +}; diff --git a/packages/node-type-registry/src/data/index.ts b/packages/node-type-registry/src/data/index.ts index 633948feb..9d7d5c0d8 100644 --- a/packages/node-type-registry/src/data/index.ts +++ b/packages/node-type-registry/src/data/index.ts @@ -1,6 +1,7 @@ export { DataCompositeField } from './data-composite-field'; export { DataDirectOwner } from './data-direct-owner'; export { DataEntityMembership } from './data-entity-membership'; +export { DataFeatureFlag } from './data-feature-flag'; export { DataForceCurrentUser } from './data-force-current-user'; export { DataId } from './data-id'; export { DataImageEmbedding } from './data-image-embedding'; @@ -8,6 +9,7 @@ export { DataImmutableFields } from './data-immutable-fields'; export { DataInflection } from './data-inflection'; export { DataInheritFromParent } from './data-inherit-from-parent'; export { DataJobTrigger } from './data-job-trigger'; +export { DataLimitCounter } from './data-limit-counter'; export { DataJsonb } from './data-jsonb'; export { DataOwnedFields } from './data-owned-fields'; export { DataOwnershipInEntity } from './data-ownership-in-entity'; From 3b3d8cd1cf63c046b0c8021697f369d469683125 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 6 May 2026 06:53:26 +0000 Subject: [PATCH 2/2] feat(node-type-registry): remove AuthzMembership, replaced by AuthzAppMembership - Delete authz-membership-check.ts (the old AuthzMembership type) - Remove export from authz/index.ts - Regenerate blueprint-types with AuthzAppMembership in the union --- .../src/authz/authz-membership-check.ts | 49 -------------- .../node-type-registry/src/authz/index.ts | 1 - .../src/blueprint-types.generated.ts | 66 +++++++++++++------ 3 files changed, 46 insertions(+), 70 deletions(-) delete mode 100644 packages/node-type-registry/src/authz/authz-membership-check.ts diff --git a/packages/node-type-registry/src/authz/authz-membership-check.ts b/packages/node-type-registry/src/authz/authz-membership-check.ts deleted file mode 100644 index bce8ce935..000000000 --- a/packages/node-type-registry/src/authz/authz-membership-check.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NodeTypeDefinition } from '../types'; - -export const AuthzMembership: NodeTypeDefinition = { - name: 'AuthzMembership', - slug: 'authz_membership_check', - category: 'authz', - display_name: 'Membership Check', - description: 'Membership check that verifies the user has membership (optionally with specific permission) without binding to any entity from the row. Uses EXISTS subquery against SPRT table.', - parameter_schema: { - type: 'object', - properties: { - membership_type: { - type: [ - 'integer', - 'string' - ], - description: 'Scope: 1=app, 2=org, 3+=dynamic entity types (or string name resolved via membership_types_module)' - }, - entity_type: { - type: 'string', - description: "Entity type prefix (e.g. 'channel', 'department'). Resolved to membership_type integer via memberships_module lookup. Use instead of membership_type for readability." - }, - permission: { - type: 'string', - description: 'Single permission name to check (resolved to bitstring mask)' - }, - permissions: { - type: 'array', - items: { - type: 'string' - }, - description: 'Multiple permission names to check (ORed together into mask)' - }, - is_admin: { - type: 'boolean', - description: 'If true, require is_admin flag' - }, - is_owner: { - type: 'boolean', - description: 'If true, require is_owner flag' - } - }, - required: [] - }, - tags: [ - 'membership', - 'authz' - ] -}; diff --git a/packages/node-type-registry/src/authz/index.ts b/packages/node-type-registry/src/authz/index.ts index 446831b78..84cd553f5 100644 --- a/packages/node-type-registry/src/authz/index.ts +++ b/packages/node-type-registry/src/authz/index.ts @@ -6,7 +6,6 @@ export { AuthzDirectOwner } from './authz-direct-owner'; export { AuthzDirectOwnerAny } from './authz-direct-owner-any'; export { AuthzEntityMembership } from './authz-entity-membership'; export { AuthzMemberList } from './authz-member-list'; -export { AuthzMembership } from './authz-membership-check'; export { AuthzNotReadOnly } from './authz-not-read-only'; export { AuthzOrgHierarchy } from './authz-org-hierarchy'; export { AuthzPeerOwnership } from './authz-peer-ownership'; diff --git a/packages/node-type-registry/src/blueprint-types.generated.ts b/packages/node-type-registry/src/blueprint-types.generated.ts index fa7092708..ee913c7c9 100644 --- a/packages/node-type-registry/src/blueprint-types.generated.ts +++ b/packages/node-type-registry/src/blueprint-types.generated.ts @@ -68,6 +68,15 @@ export interface DataEntityMembershipParams { /* If true, adds a foreign key constraint from entity_id to the users table */ include_user_fk?: boolean; } +/** Gates a table behind a feature flag backed by the cap tables. Attaches a BEFORE INSERT trigger that checks whether the named feature cap value is > 0. Features are modeled as caps with max=0 (disabled) or max=1 (enabled) in limit_caps / limit_caps_defaults tables. Resolution: COALESCE(per-entity cap, scope default, 0). */ +export interface DataFeatureFlagParams { + /* Cap name representing this feature (must match a limit_caps_defaults entry with max=0 or max=1) */ + feature_name: string; + /* Feature scope: "app" (membership_type=1, app-level caps) or "org" (membership_type=2, per-entity caps) */ + scope?: 'app' | 'org'; + /* Column on the target table that holds the entity id for per-entity cap lookups (only used for org scope) */ + entity_field?: string; +} /** BEFORE INSERT trigger that forces a field to the value of jwt_public.current_user_id(). Prevents clients from spoofing the actor/uploader identity. The field value is always overwritten regardless of what the client provides. */ export interface DataForceCurrentUserParams { /* Name of the field to force to current_user_id() */ @@ -157,6 +166,17 @@ export interface DataJobTriggerParams { /* Maximum retry attempts for the job */ max_attempts?: number; } +/** Declaratively attaches limit-tracking triggers to a table. On INSERT the named limit is incremented; on DELETE it is decremented. Requires a provisioned limits_module for the target scope. */ +export interface DataLimitCounterParams { + /* Name of the limit to track (must match a default_limits entry, e.g. "projects", "members") */ + limit_name: string; + /* Limit scope: "app" (membership_type=1, user-level) or "org" (membership_type=2, entity-level) */ + scope?: 'app' | 'org'; + /* Column on the target table that holds the actor or entity id used for limit lookup */ + actor_field?: string; + /* Which DML events to attach triggers for */ + events?: ('INSERT' | 'DELETE' | 'UPDATE')[]; +} /** Adds a JSONB column with optional GIN index for containment queries (@>, ?, ?|, ?&). Standard pattern for semi-structured metadata. */ export interface DataJsonbParams { /* Column name for the JSONB field */ @@ -432,6 +452,21 @@ export interface SearchVectorParams { ; /** Allows all access. Generates TRUE expression. */ export type AuthzAllowAllParams = {}; +/** App-level membership check (membership_type=1). Verifies the user has app membership (optionally with specific permission) without binding to any entity from the row. Uses EXISTS subquery against SPRT table. Replaces AuthzMembership for clarity. */ +export interface AuthzAppMembershipParams { + /* Scope: 1=app, 2=org, 3+=dynamic entity types (or string name resolved via membership_types_module) */ + membership_type?: number | string; + /* Entity type prefix (e.g. 'channel', 'department'). Resolved to membership_type integer via memberships_module lookup. Use instead of membership_type for readability. */ + entity_type?: string; + /* Single permission name to check (resolved to bitstring mask) */ + permission?: string; + /* Multiple permission names to check (ORed together into mask) */ + permissions?: string[]; + /* If true, require is_admin flag */ + is_admin?: boolean; + /* If true, require is_owner flag */ + is_owner?: boolean; +} /** Composite authorization policy that combines multiple authorization nodes using boolean logic (AND/OR). The data field contains a JSONB AST with nested authorization nodes. */ export interface AuthzCompositeParams { /* Boolean expression combining multiple authorization nodes */ @@ -478,21 +513,6 @@ export interface AuthzMemberListParams { /* Column name containing the array of user IDs */ array_field: string; } -/** Membership check that verifies the user has membership (optionally with specific permission) without binding to any entity from the row. Uses EXISTS subquery against SPRT table. */ -export interface AuthzMembershipParams { - /* Scope: 1=app, 2=org, 3+=dynamic entity types (or string name resolved via membership_types_module) */ - membership_type?: number | string; - /* Entity type prefix (e.g. 'channel', 'department'). Resolved to membership_type integer via memberships_module lookup. Use instead of membership_type for readability. */ - entity_type?: string; - /* Single permission name to check (resolved to bitstring mask) */ - permission?: string; - /* Multiple permission names to check (ORed together into mask) */ - permissions?: string[]; - /* If true, require is_admin flag */ - is_admin?: boolean; - /* If true, require is_owner flag */ - is_owner?: boolean; -} /** Restrictive policy that blocks read-only members from mutations. Checks actor_id + is_read_only IS NOT TRUE on the SPRT. Designed to run as a restrictive counterpart after a permissive AuthzEntityMembership policy has already verified membership. */ export interface AuthzNotReadOnlyParams { /* Column name referencing the entity (e.g., entity_id, org_id) */ @@ -809,7 +829,7 @@ export interface BlueprintField { /** An RLS policy entry for a blueprint table. Uses $type to match the blueprint JSON convention. */ export interface BlueprintPolicy { /** Authz* policy type name (e.g., "AuthzDirectOwner", "AuthzAllowAll"). */ - $type: 'AuthzAllowAll' | 'AuthzComposite' | 'AuthzDenyAll' | 'AuthzDirectOwner' | 'AuthzDirectOwnerAny' | 'AuthzEntityMembership' | 'AuthzMemberList' | 'AuthzMembership' | 'AuthzNotReadOnly' | 'AuthzOrgHierarchy' | 'AuthzPeerOwnership' | 'AuthzPublishable' | 'AuthzRelatedEntityMembership' | 'AuthzRelatedMemberList' | 'AuthzRelatedPeerOwnership' | 'AuthzTemporal'; + $type: 'AuthzAllowAll' | 'AuthzAppMembership' | 'AuthzComposite' | 'AuthzDenyAll' | 'AuthzDirectOwner' | 'AuthzDirectOwnerAny' | 'AuthzEntityMembership' | 'AuthzMemberList' | 'AuthzNotReadOnly' | 'AuthzOrgHierarchy' | 'AuthzPeerOwnership' | 'AuthzPublishable' | 'AuthzRelatedEntityMembership' | 'AuthzRelatedMemberList' | 'AuthzRelatedPeerOwnership' | 'AuthzTemporal'; /** Privileges this policy applies to (e.g., ["select"], ["insert", "update", "delete"]). */ privileges?: string[]; /** Whether this policy is permissive (true) or restrictive (false). Defaults to true. */ @@ -993,11 +1013,14 @@ export interface BlueprintEntityType { */ ; /** String shorthand -- just the node type name. */ -export type BlueprintNodeShorthand = 'AuthzAllowAll' | 'AuthzComposite' | 'AuthzDenyAll' | 'AuthzDirectOwner' | 'AuthzDirectOwnerAny' | 'AuthzEntityMembership' | 'AuthzMemberList' | 'AuthzMembership' | 'AuthzNotReadOnly' | 'AuthzOrgHierarchy' | 'AuthzPeerOwnership' | 'AuthzPublishable' | 'AuthzRelatedEntityMembership' | 'AuthzRelatedMemberList' | 'AuthzRelatedPeerOwnership' | 'AuthzTemporal' | 'DataCompositeField' | 'DataDirectOwner' | 'DataEntityMembership' | 'DataForceCurrentUser' | 'DataId' | 'DataImageEmbedding' | 'DataImmutableFields' | 'DataInflection' | 'DataInheritFromParent' | 'DataJobTrigger' | 'DataJsonb' | 'DataOwnedFields' | 'DataOwnershipInEntity' | 'DataPeoplestamps' | 'DataPublishable' | 'DataSlug' | 'DataSoftDelete' | 'DataStatusField' | 'DataTags' | 'DataTimestamps' | 'SearchBm25' | 'SearchFullText' | 'SearchSpatial' | 'SearchSpatialAggregate' | 'SearchTrgm' | 'SearchUnified' | 'SearchVector' | 'TableOrganizationSettings' | 'TableUserProfiles' | 'TableUserSettings'; +export type BlueprintNodeShorthand = 'AuthzAllowAll' | 'AuthzAppMembership' | 'AuthzComposite' | 'AuthzDenyAll' | 'AuthzDirectOwner' | 'AuthzDirectOwnerAny' | 'AuthzEntityMembership' | 'AuthzMemberList' | 'AuthzNotReadOnly' | 'AuthzOrgHierarchy' | 'AuthzPeerOwnership' | 'AuthzPublishable' | 'AuthzRelatedEntityMembership' | 'AuthzRelatedMemberList' | 'AuthzRelatedPeerOwnership' | 'AuthzTemporal' | 'DataCompositeField' | 'DataDirectOwner' | 'DataEntityMembership' | 'DataFeatureFlag' | 'DataForceCurrentUser' | 'DataId' | 'DataImageEmbedding' | 'DataImmutableFields' | 'DataInflection' | 'DataInheritFromParent' | 'DataJobTrigger' | 'DataLimitCounter' | 'DataJsonb' | 'DataOwnedFields' | 'DataOwnershipInEntity' | 'DataPeoplestamps' | 'DataPublishable' | 'DataSlug' | 'DataSoftDelete' | 'DataStatusField' | 'DataTags' | 'DataTimestamps' | 'SearchBm25' | 'SearchFullText' | 'SearchSpatial' | 'SearchSpatialAggregate' | 'SearchTrgm' | 'SearchUnified' | 'SearchVector' | 'TableOrganizationSettings' | 'TableUserProfiles' | 'TableUserSettings'; /** Object form -- { $type, data } with typed parameters. */ export type BlueprintNodeObject = { $type: 'AuthzAllowAll'; data?: Record; +} | { + $type: 'AuthzAppMembership'; + data: AuthzAppMembershipParams; } | { $type: 'AuthzComposite'; data: AuthzCompositeParams; @@ -1016,9 +1039,6 @@ export type BlueprintNodeObject = { } | { $type: 'AuthzMemberList'; data: AuthzMemberListParams; -} | { - $type: 'AuthzMembership'; - data: AuthzMembershipParams; } | { $type: 'AuthzNotReadOnly'; data: AuthzNotReadOnlyParams; @@ -1052,6 +1072,9 @@ export type BlueprintNodeObject = { } | { $type: 'DataEntityMembership'; data: DataEntityMembershipParams; +} | { + $type: 'DataFeatureFlag'; + data: DataFeatureFlagParams; } | { $type: 'DataForceCurrentUser'; data: DataForceCurrentUserParams; @@ -1073,6 +1096,9 @@ export type BlueprintNodeObject = { } | { $type: 'DataJobTrigger'; data: DataJobTriggerParams; +} | { + $type: 'DataLimitCounter'; + data: DataLimitCounterParams; } | { $type: 'DataJsonb'; data: DataJsonbParams;