Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/blocks/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandPalette registry={registry} navigate={(href) => 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"
],
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
5 changes: 4 additions & 1 deletion apps/registry/scripts/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
4 changes: 2 additions & 2 deletions apps/registry/scripts/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion packages/schema-builder/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import {
Combobox,
ComboboxEmpty,
Expand All @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 (
<MultiSelect
options={options}
defaultValue={value ?? []}
onValueChange={(next) => 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`}
Expand Down
2 changes: 1 addition & 1 deletion packages/sheets/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading