diff --git a/apps/blocks/registry.json b/apps/blocks/registry.json index a1296c6..2483e58 100644 --- a/apps/blocks/registry.json +++ b/apps/blocks/registry.json @@ -269,7 +269,7 @@ ], "docs": "## Usage\n\nCreate one registry for the application, adapt navigation at the host boundary, and render the palette near the root layout. Pages can register commands while mounted with `usePageCommands`.\n\n```tsx\nimport { createCommandRegistry, kbd } from '@constructive-io/command-palette';\nimport { CommandPalette } from '@/blocks/command-palette/command-palette';\n\nconst registry = createCommandRegistry({\n groups: [{ id: 'navigation', label: 'Navigation', priority: 1 }],\n commands: [{\n id: 'settings',\n label: 'Open settings',\n type: 'navigation',\n group: 'navigation',\n href: '/settings',\n shortcut: kbd(',', 'mod')\n }]\n});\n\n router.push(href)} />;\n```\n\nThe installed block owns presentation and keyboard interaction. The headless package owns command registration, execution, multi-step state, and background-task lifecycle. Route authorization, action permissions, errors, and business workflows remain the host application's responsibility.", "dependencies": [ - "@constructive-io/command-palette@^0.4.0", + "@constructive-io/command-palette@^0.5.0", "lucide-react", "motion" ], @@ -436,7 +436,7 @@ "description": "The leaf-independent Console Kit shell, runtime, discovery, and single modular Zustand store.", "docs": "`console-kit-core` installed the shell, runtime, semantic routing, callback boundary, and one per-instance modular Zustand store. Core intentionally includes no feature view, so add selected `console-module-*` items.\n\nDegraded states: explicit endpoint, current `_meta`, introspection, capability, and adapter evidence fail closed independently; installation never grants authority.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "@tanstack/react-query", "graphql", "lucide-react", @@ -566,7 +566,7 @@ "description": "A current-_meta-only application data explorer with application-table filtering and spreadsheet CRUD.", "docs": "`feature-pack-data` installed a provider-neutral view and `.constructive/feature-packs/data.json`. Import from `@/blocks/feature-packs/data/data-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-data` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: The host owns Sheets state and endpoint binding; incompatible metadata stays explicit, and PostgreSQL privileges and RLS decide every query and mutation.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/data/", "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "lucide-react" ], "registryDependencies": [ diff --git a/apps/registry/scripts/compiler.test.ts b/apps/registry/scripts/compiler.test.ts index 03fe929..94de12f 100644 --- a/apps/registry/scripts/compiler.test.ts +++ b/apps/registry/scripts/compiler.test.ts @@ -397,7 +397,10 @@ test('enforces the compiled registry distribution contract', () => { const wrongRange = structuredClone(distributionContractFixture()); wrongRange.find((item) => item.name === 'consumer')!.dependencies = ['@constructive-io/data@^1.0.0']; - assert.throws(() => assertRegistryDistributionContract(wrongRange), /must depend on @constructive-io\/data@\^0\.5\.0/); + assert.throws( + () => assertRegistryDistributionContract(wrongRange), + new RegExp(`must depend on ${CONSTRUCTIVE_DATA_DEPENDENCY.replace(/[.^]/g, '\\$&')}`), + ); const sourceOwnedPackage = structuredClone(distributionContractFixture()); sourceOwnedPackage.find((item) => item.name === 'consumer')!.dependencies = [CONSTRUCTIVE_UI_PACKAGE]; diff --git a/apps/registry/scripts/compiler.ts b/apps/registry/scripts/compiler.ts index cfd3632..ec716a7 100644 --- a/apps/registry/scripts/compiler.ts +++ b/apps/registry/scripts/compiler.ts @@ -5,8 +5,8 @@ export const CONSTRUCTIVE_UI_PACKAGE = '@constructive-io/ui'; export const CONSTRUCTIVE_SHEETS_PACKAGE = '@constructive-io/sheets'; export const CONSTRUCTIVE_NAMESPACE = '@constructive/'; export const CONSTRUCTIVE_THEME_DEPENDENCY = '@constructive/constructive-theme'; -export const CONSTRUCTIVE_DATA_DEPENDENCY = '@constructive-io/data@^0.5.0'; -export const CONSTRUCTIVE_COMMAND_PALETTE_DEPENDENCY = '@constructive-io/command-palette@^0.4.0'; +export const CONSTRUCTIVE_DATA_DEPENDENCY = '@constructive-io/data@^0.7.0'; +export const CONSTRUCTIVE_COMMAND_PALETTE_DEPENDENCY = '@constructive-io/command-palette@^0.5.0'; export const NODE_TYPE_REGISTRY_DEPENDENCY = 'node-type-registry@^1.11.0'; export const FEATURE_PACK_IDS = [ diff --git a/packages/schema-builder/registry.json b/packages/schema-builder/registry.json index c4057a5..7e2262c 100644 --- a/packages/schema-builder/registry.json +++ b/packages/schema-builder/registry.json @@ -14,7 +14,7 @@ "schema" ], "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "@dnd-kit/core", "@dnd-kit/utilities", "@fluentui/react-context-selector", diff --git a/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts new file mode 100644 index 0000000..3421225 --- /dev/null +++ b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import type { CapabilityNode } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; +import { decodeMask } from './dynamic-form-field'; + +const bit = (bitnum: number, width = 64) => '0'.repeat(width - 1 - bitnum) + '1' + '0'.repeat(bitnum); + +const capability = (name: string, bitnum: number, kind: CapabilityNode['kind'] = 'permission'): CapabilityNode => ({ + id: name, + name, + bitnum, + bitstr: bit(bitnum), + description: null, + kind, +}); + +const catalog = [ + capability('admin_members', 0), + capability('create_entity', 4), + capability('add_credits', 6), + capability('level.trusted', 40, 'level'), +]; + +describe('decodeMask', () => { + it('names the bits the mask sets', () => { + const mask = bit(0).split(''); + mask[63 - 6] = '1'; + expect(decodeMask(mask.join(''), catalog, 'permission')).toEqual(['admin_members', 'add_credits']); + }); + + it('returns nothing for an empty mask', () => { + expect(decodeMask('0'.repeat(64), catalog, 'permission')).toEqual([]); + }); + + it('keeps levels out of the capability picker and vice versa', () => { + expect(decodeMask(bit(40), catalog, 'permission')).toEqual([]); + expect(decodeMask(bit(40), catalog, 'level')).toEqual(['level.trusted']); + }); + + // A policy compiled before the module grew is narrower than the catalog's + // bitstrings; bits are numbered from the right, so both still line up. + it('reads a mask narrower than the catalog', () => { + expect(decodeMask(bit(4, 17), catalog, 'permission')).toEqual(['create_entity']); + }); +}); diff --git a/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx index d0d7a0c..d96fce8 100644 --- a/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx +++ b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Combobox, ComboboxEmpty, @@ -25,7 +25,7 @@ import { Info } from 'lucide-react'; import { MEMBERSHIP_TYPES } from '@/blocks/schema/schema-builder-core/lib/constants/membership-types'; import type { PolicyTableData } from '@/blocks/schema/schema-builder-core/lib/gql/hooks/schema-builder/policies/use-database-policies'; -import type { CapabilityKind } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; +import type { CapabilityKind, CapabilityNode } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; import { useCapabilities } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; import { MultiValueFieldEditor } from '../multi-value-field-editor'; @@ -212,6 +212,28 @@ function DependentFieldSelectField({ ); } +/** Whether two bitstrings of possibly different widths share a set bit. */ +function bitsIntersect(a: string, b: string) { + for (let i = 1; i <= Math.min(a.length, b.length); i += 1) { + if (a[a.length - i] === '1' && b[b.length - i] === '1') return true; + } + return false; +} + +/** + * The names a mask requires, per the catalog it was compiled against. + * + * A saved policy stores only the compiled mask, so reopening one has to decode + * it: each catalog row owns one bit, and a name is required when its bit is set. + * Nothing is persisted twice — the mask stays the single source of truth. + */ +export function decodeMask(mask: string, capabilities: CapabilityNode[], kind: CapabilityKind) { + return capabilities + .filter((capability) => capability.name && capability.kind === kind && capability.bitstr) + .filter((capability) => bitsIntersect(mask, capability.bitstr as string)) + .map((capability) => capability.name); +} + /** * Multi-select over the capability catalog, populated from useCapabilities. * @@ -253,14 +275,28 @@ function CapabilitySelectField({ [capabilitiesList, kind], ); + // An unedited policy carries no names, only the mask the parser compiled. + // Once the operator has touched the picker their selection wins, so clearing + // it does not snap back to the mask it was opened with. + const [isTouched, setIsTouched] = useState(false); + const selected = useMemo(() => { + if (value) return value; + const mask = formData?.mask; + if (isTouched || typeof mask !== 'string') return []; + return decodeMask(mask, capabilitiesList, kind); + }, [value, formData?.mask, isTouched, capabilitiesList, kind]); + const noun = kind === 'level' ? 'levels' : 'capabilities'; const scope = isAppLevel ? 'app' : 'membership'; return ( onChange(next.length > 0 ? next : undefined)} + defaultValue={selected} + onValueChange={(next) => { + setIsTouched(true); + onChange(next.length > 0 ? next : undefined); + }} disabled={disabled} placeholder={isLoading ? 'Loading...' : `Select ${scope} ${noun}`} emptyIndicator={`No ${scope} ${noun} defined`} diff --git a/packages/sheets/registry.json b/packages/sheets/registry.json index dcbbc2b..64d6c1d 100644 --- a/packages/sheets/registry.json +++ b/packages/sheets/registry.json @@ -14,7 +14,7 @@ "data" ], "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "@internationalized/date", "@remixicon/react", "@tanstack/react-form",