diff --git a/.agents/skills/constructive-auth/references/auth-flow.md b/.agents/skills/constructive-auth/references/auth-flow.md index babef24..2f9f142 100644 --- a/.agents/skills/constructive-auth/references/auth-flow.md +++ b/.agents/skills/constructive-auth/references/auth-flow.md @@ -14,7 +14,7 @@ const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' await authDb.mutation.signUp( { input: { email, password } }, - { select: { ok: true, errors: true } } + { select: { result: { select: { id: true } } } } ).execute(); ``` @@ -110,13 +110,17 @@ localStorage.setItem('device_token', r.outDeviceToken); ### Sign up (first device auto-approved) ```typescript +// `deviceToken` is a SignUpInput field; everything you read back is selected off +// `result` (a SignUpRecord) — there is no top-level field on SignUpPayload. +// `outDeviceToken` is only present when `devices_module` is installed (see §intro). const result = await authDb.mutation.signUp( { input: { email, password, deviceToken: '' } }, - { select: { outDeviceToken: true, accessToken: true } } + { select: { result: { select: { accessToken: true, outDeviceToken: true } } } } ).execute(); -// First device is auto-approved even when require_device_approval is on -localStorage.setItem('device_token', result.signUp.outDeviceToken); +// First device is auto-approved even when require_device_approval is on — +// persist the returned device token for future logins. +localStorage.setItem('device_token', result.signUp.result.outDeviceToken); ``` See [`constructive-platform/references/device-settings.md`](../../constructive-platform/references/device-settings.md) for the full composition matrix of device settings. diff --git a/.agents/skills/constructive-blocks.zip b/.agents/skills/constructive-blocks.zip new file mode 100644 index 0000000..4f9a8cd Binary files /dev/null and b/.agents/skills/constructive-blocks.zip differ diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md new file mode 100644 index 0000000..afe3163 --- /dev/null +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -0,0 +1,287 @@ +--- +name: constructive-blocks +description: "Install, wire, and author Constructive Blocks — copy-in React UI blocks (auth sign-in card, account, membership/invite flows) distributed via a shadcn registry that bind to the host app's per-application generated GraphQL SDK. Use when asked to add/install a Constructive block, run `shadcn add @constructive/`, wire `blocks-runtime`, alias `@/generated/*`, generate a missing SDK with `cnc codegen`, write or check a `.requires.json` manifest, run `check-sdk.mjs`, or author a new block against the generated React Query hooks. Enforces the SDK Binding Contract: a block imports generated hooks, never network code." +compatibility: Node.js 18+; host app on Next.js (App Router) + React 19 + @tanstack/react-query + a Constructive-generated SDK +allowed-tools: Bash, Read, Edit, Write, Glob, Grep +license: MIT +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Constructive Blocks + +Constructive Blocks are **copy-in** React UI blocks — auth, account, membership, invite, and object flows — shipped through a shadcn registry (`@constructive/`). You install a block's source *into* the host app; it is then ordinary, editable app code. + +A block is not a generic component. It binds to **your app's own generated GraphQL SDK** and is correct by construction for *that* app's schema. This skill is the operator's playbook for installing, wiring, checking, and authoring blocks without violating that binding. + +## The doctrine in one sentence + +> A data block imports generated **React Query hooks** from `@/generated/` — the SDK the *host* produced from *its own* PostGraphile endpoints — and ships **no network code of its own**. + +`@constructive-io/data`, `@constructive-io/sdk`, ``, a hand-written `fetch`, or a hardcoded `src/graphql/...` path are all the **wrong** frame. The binding is the generated hook + a convention alias. The full law is [`references/binding-doctrine.md`](./references/binding-doctrine.md) (a condensation of the canonical SDK Binding Contract); it **wins** over any older blocks doc. + +## When to Apply + +Use this skill when: + +- **Installing a block**: "add the sign-in card", `npx shadcn add @constructive/auth-sign-in-card`, or wiring any `@constructive/*` block into an app. +- **Preflight / checking**: running `check-sdk.mjs`, diagnosing "block compiles against a missing operation", verifying a `.requires.json`. +- **Host wiring**: aliasing `@/generated/*`, mounting ``, adding a namespace to the runtime, generating a missing SDK with `cnc codegen`. +- **Authoring a block**: writing a new block that calls a generated hook, choosing its namespace, declaring its `requires.json`, adding the override seam. + +**Scope boundary — Blocks are auth/account/org/shell ONLY.** The catalogued blocks and flows cover **auth, account, organization, and app-shell** capability bundles (sign-in, password reset, MFA, membership, invites, settings). They are **not** a general application-flow library. For your **domain-entity CRUD UI** — the React UI over your own business tables — use **`constructive-frontend`** (CRUD Stack cards + runtime-generic `_meta` meta-forms), **not** blocks; the toolkit automates that path via `scripts/scaffold-frontend.mjs`. A "flow" here answers *"which auth flow?"*, never *"which business workflow?"*. + +If the request is about generating the SDK itself, defer to the codegen skills — this skill *consumes* that SDK: **`constructive-codegen`** (codegen CLI/config flags), **`constructive-hooks`** / **`constructive-orm`** (generated hook/ORM output shapes, pagination), **`constructive-search`** (search). + +## Host setup — three steps (once per app) + +A block compiles only if the host satisfies all three. `check-sdk.mjs` verifies steps 1–2; you do step 3 once. + +**1. Generate the SDK** for each namespace the app uses, into `src/generated/`: + +```bash +# By API name against the app database (auto-expands to multi-target): +cnc codegen --api-names auth,admin --react-query --orm -o src/generated +# …or per endpoint: +cnc codegen --endpoint https://auth./graphql --react-query --orm -o src/generated/auth +``` + +`--react-query` **and** `--orm` are both required — hooks wrap the ORM client and the runtime's `configure()` lives in the ORM layer. Generated files are stamped `DO NOT EDIT`; never hand-edit them, regenerate. + +**2. Alias `@/generated/*`** to the generated output in `tsconfig.json` (and the bundler if it doesn't read tsconfig paths): + +```jsonc +{ "compilerOptions": { "paths": { "@/generated/*": ["./src/generated/*"] } } } +``` + +**3. Mount `` once at the app root.** It is a `registryDependency` of every data block (installed automatically), so this is the only provider wiring a human writes: + +```tsx +// app/layout.tsx +import { BlocksRuntime } from '@/blocks/runtime/blocks-runtime'; +import { tokenManager } from '@/lib/auth'; + +export default function RootLayout({ children }) { + return ( + tokenManager.getAccessToken()}> + {children} + + ); +} +``` + +The runtime mounts **one** shared `QueryClient`, calls each namespace's generated `configure()` (reading `NEXT_PUBLIC__GRAPHQL_ENDPOINT`), and attaches `Authorization: Bearer ` via the host's `getToken`. A block **never** mounts a provider or calls `configure()`. + +## Flow selection (start here) + +**Before** you install any block, pick the **flow(s)** the app needs. A *flow* is a backend-capability bundle — it answers *"which auth flow do you want?"* with the exact database **modules** to provision, the GraphQL **operations** that go live, and the **blocks** that wire the UI. Every catalogued flow is **GA** (DB-wired, GraphQL-exposed, blocks resolve). This is the catalog-first analogue of better-auth's plugins, and it is the cure for the `modules:['all']` over-provisioning trap. + +The catalog is two co-located files (both generated from one source of truth in apps/blocks — never hand-edit them): + +- **[`references/flows.json`](./references/flows.json)** — the machine-readable catalog: each flow's `backend.preset`, the resolved flat `backend.modules[]`, `backend.exposedOps[]`, and `blocks[]`. Read this to drive provisioning + install programmatically. +- **[`references/flow-catalog.md`](./references/flow-catalog.md)** — the human-readable index of the same data. + +### Decision procedure + +1. **Read the brief → list the capabilities** the app needs (e.g. "sign in, reset password, manage org members"). +2. **Map each capability to a flow id** in `references/flows.json` (e.g. `email-password`, `password-reset`, `org-members`). Pick the minimal set that covers the brief. +3. **Provision the UNION of the chosen flows' `backend.modules[]`** — the exact flat list, deduplicated across flows. Pass it to `databaseProvisionModule.create({ data: { modules } })`. **Never `modules:['all']`.** A flow's `modules[]` is authoritative; `preset` is only the smallest covering shipped preset (advisory). Org flows have no preset smaller than `b2b`. +4. **Install ONLY the chosen flows' `blocks[]`** — not the whole library. `npx shadcn@latest add …` for the union of the flows' blocks. +5. **Run `check-sdk.mjs`** (below) for each installed data block — it proves the host SDK actually exposes the ops the flow's blocks call, before you waste a build. + +```bash +# Example: brief needs sign-in + password reset. +# flows.json → email-password (preset auth:email) + password-reset (preset auth:email). +# Union of modules is the auth:email set (same preset) → provision that once, then: +npx shadcn@latest add auth-sign-in-card auth-sign-up-card auth-sign-out-button \ + auth-forgot-password-card auth-reset-password-card +node path/to/skill/scripts/check-sdk.mjs # gate every installed data block +``` + +If a needed capability is **not** in the catalog (magic-link, OTP, MFA enroll, passkey, anonymous, SSO/SCIM stubs, context-switch), its blocks exist in the library but are **not GA** — they ship a "backend-pending" banner and their `requires.json` names a not-yet-deployed op, so `check-sdk.mjs` fails clearly rather than letting you build against a guess. + +## Installing a block + +```bash +# 1. Pull the block's source into the app (also installs its registry deps: +# blocks-runtime, foundation libs, primitives, cn). +npx shadcn add @constructive/auth-sign-in-card + +# 2. Preflight: prove the host SDK actually exports every op the block needs. +node path/to/skill/scripts/check-sdk.mjs auth-sign-in-card +``` + +Step 1 also writes the block's manifest to `.constructive/blocks/.requires.json` — relative to wherever the blocks registry target lives, so on a standard Next.js `src/` layout it lands at **`src/.constructive/blocks/.requires.json`**. `check-sdk.mjs` auto-discovers both the project-root and `src/` locations (use `--manifests-dir DIR` for anything non-standard). **Always run step 2 after installing a data block** — it is the §9 enforcement gate. A green check means the block will compile against real operations; a red check names the exact missing op *before* you waste a build. + +Then render it: + +```tsx +import { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card'; + + router.push('/')} + forgotPasswordHref="/forgot" + signUpHref="/register" +/> +``` + +## The `requires.json` manifest + +Every **data block** ships a co-located, machine-readable manifest declaring exactly what the host SDK must expose. It lands at `.constructive/blocks/.requires.json` on install — under `src/` when the blocks target lives there (`src/.constructive/blocks/.requires.json`), which is the usual Next.js layout: + +```json +{ "namespace": "auth", "mutations": ["signIn"], "queries": [], "models": [] } +``` + +- `namespace` — the generated namespace the block imports from (`auth`, `admin`, `objects`, `public`, …). +- `mutations` / `queries` — **GraphQL operation names** (camelCase, post-inflection) the block calls. `signIn` (not `useSignInMutation`) — the manifest names the *operation*; the check derives the hook. +- `models` — table model accessors the block needs (only when it uses a `useQuery` list hook; see the Connection rule below). + +**Presentational blocks ship no manifest.** A cross-namespace block uses one shape consistently — see [`references/manifest-and-checks.md`](./references/manifest-and-checks.md) for the authoritative schema (single-object vs `requires: [...]` array) and rules. + +## `check-sdk.mjs` — the preflight gate + +Zero-dependency Node (≥18). Run from the host app root: + +```bash +node scripts/check-sdk.mjs # check every installed manifest +node scripts/check-sdk.mjs auth-sign-in-card # check one block (name or manifest path) +node scripts/check-sdk.mjs --project /path/app --json +``` + +It (1) verifies the `@/generated/*` alias exists in `tsconfig.json`, (2) resolves and checks the generated dir for each block's namespace, (3) asserts every manifest op maps to a real SDK export (`signIn` → `useSignInMutation`), (4) advises whether `` is mounted, and (5) emits **contract advisories** (WARN-only) for known arg-domain / defective ops an installed block touches — see the **(B)** table under "Known SDK gaps". **Exit codes: `0`** satisfied · **`1`** a prerequisite is missing · **`2`** the check couldn't run (no tsconfig / bad manifest). **Contract advisories never change the exit code** — they're read from `warnings[]` in `--json`. + +On failure it prints the exact remediation: + +- **Alias or generated dir missing** → it prints the `cnc codegen --api-names --react-query --orm -o src/generated` to run, then re-check. +- **SDK present but an op is absent** → the backend likely hasn't deployed that procedure, or the SDK is stale. Regenerate and drift-check with `cnc codegen … --dry-run`. + +It also prints **contract advisories** (WARN, exit code unchanged): a `⚠` line per known arg-domain / defective op an installed block touches (see the **(B)** table above). For an **arg-domain** WARN, pass the safe value (e.g. `createApiKey` → `read_only`/`full_access`, not `read`/`write`/`admin`). For a **defective** WARN (GAP-N), the op is upstream-broken — don't build a flow that depends on it succeeding; treat it as backend-pending. The toolkit reads these from `warnings[]` in `--json` (`node scripts/check-sdk.mjs --json`). + +**This script never runs `cnc codegen` itself** — generation needs an endpoint and operator confirmation. It *detects*; you *remediate*. If the SDK is genuinely missing, confirm the endpoint/api-names with the operator, run `cnc codegen`, then re-run the check. + +## Extending the runtime with a new namespace + +`blocks-runtime.tsx` is the host's wiring point, not a leaf block — editing it is expected. To support a namespace beyond `auth`/`admin`, make exactly three matched edits: + +```tsx +import { configure as configureObjects } from '@/generated/objects'; // 1. import its configure() +export type BlocksNamespace = 'auth' | 'admin' | 'objects'; // 2. widen the union +const CONFIGURERS = { auth: configureAuth, admin: configureAdmin, objects: configureObjects }; +const ENDPOINTS = { + auth: process.env.NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT, + admin: process.env.NEXT_PUBLIC_ADMIN_GRAPHQL_ENDPOINT, + objects: process.env.NEXT_PUBLIC_OBJECTS_GRAPHQL_ENDPOINT, // 3. add the literal env var +}; +``` + +The env var **must** be referenced literally (`process.env.NEXT_PUBLIC_OBJECTS_GRAPHQL_ENDPOINT`), never as `process.env[\`NEXT_PUBLIC_${ns}_...\`]` — Next.js only inlines literal references. + +## Generated hook anatomy + +Block authors call the **real generated names** and pass a `selection` — never guess a signature; verify it in the generated `.d.ts`. + +| Operation kind | Generated hook | Example | +|---|---|---| +| Custom operation | `useMutation` | `signIn` → `useSignInMutation` | +| Table read (list / one) | `useQuery` / `useQuery` | `useUsersQuery`, `useUserQuery` | +| Table write | `useCreate/Update/DeleteMutation` | `useCreateApiKeyMutation` | + +```tsx +const signIn = useSignInMutation({ + selection: { fields: { result: { select: { userId: true, mfaRequired: true } } } }, +}); +await signIn.mutateAsync({ email, password, rememberMe }); +``` + +**Connection rule (critical):** a model accessor + `useQuery` list hook exist **iff** the SDL has a `*Connection` type for that table. Tables exposed only as private-schema views get no list hook — only their explicit mutations. This is why sessions/api-keys are not listable (see gaps below). + +## Known SDK gaps (consequences, not bugs) + +There are **two** distinct gap classes, surfaced by `check-sdk.mjs` in two different ways: + +**(A) Absent ops — caught by the binding gate (HARD-FAIL on import, ◦ when degraded).** The op isn't in the SDK at all (not-yet-deployed proc or no Connection type). A block that *imports* it fails the check; a block that *declares but degrades* (never imports it) reports `◦` and passes. + +| Capability | Status | Block handling | +|---|---|---| +| List active sessions | No Connection type (`user_sessions` is private) → no list hook | `auth-account-sessions-list` is **out of frontend scope** until an API exposes a sessions Connection. Only `revokeSession` exists. | +| List API keys | Same — `user_api_keys` is private | `auth-account-api-keys-list` likewise out of scope; `createApiKey`/`revokeApiKey` exist. | +| Passkeys / TOTP-enroll / magic-link / email-OTP / anonymous / context-switch / org transfer+delete (`removeOrgMember` / `transferOrgOwnership` / `delete_org`) | Procedures **not yet deployed** in any public schema | Blocks kept **backend-pending** with a "not buildable until proc ships" banner; their `requires.json` names the pending op so `check-sdk.mjs` fails clearly (or marks it `◦` when degraded). Route member-remove through GA `deleteOrgMembership`. | + +A block whose required op is absent **fails the check with a precise message** rather than compiling against a guess — that is the gap surfacing honestly, not a defect. + +**(B) Present-but-defective ops — surfaced by the CONTRACT PREFLIGHT (WARN, never a failure).** These ops *exist* and type-check (they pass the binding gate), but calling them the way a block ships fails at **runtime**: a wrong **arg-domain** (a live `INVALID_ACCESS_LEVEL`) or a known **upstream defect** (silent no-op / RLS-deny / abort). The binding gate can't see this — the export is present — so `check-sdk.mjs` emits a **contract advisory** naming the op, the GAP-N, and the safe value. **This table is the source `check-sdk.mjs` mirrors** (the `KNOWN_AXES` table in the script); keep them in sync — a new row here with an op signature should gain a `KNOWN_AXES` entry. The advisories appear under "⚠ contract advisories" in the human report and as a `warnings[]` array in `--json`. Based on the build flow's confirmed-live facts in **`PLATFORM-GAPS.md`** + **`planning/upstream-gaps-stress-test-2026-06-05.md`**. + +| Op(s) | Axis | GAP | Safe value / behavior | +|---|---|---|---| +| `createApiKey` | **arg-domain** `accessLevel ∈ {read_only, full_access}` | auth-api-key axis | The `auth-api-key-create-dialog` ships `{read, write, admin}` → live **`INVALID_ACCESS_LEVEL`**. Pass `read_only` or `full_access`. (`createApiKey` also enforces `STEP_UP_REQUIRED` server-side.) | +| `createUser(type=2)` / `createOrganization` | **defective** (RLS-deny) | GAP-6 | RLS-denied for an authenticated session (`new row violates row-level security policy for table "users"`) — no self-service org can be minted on the b2b tier. Confirmed via both the block and the direct API. Upstream (constructive-db). | +| `userSessions` / `sessions` (list) | **defective** (no Connection) | GAP-2 | No `userSessions` list query is exposed → the Sessions flow can't enumerate sessions to revoke. Out of frontend scope until a Connection ships. | +| `revokeSession` | **defective** (id mismatch) | GAP-2 | Returns `SESSION_NOT_FOUND` for the id on a `signIn`/`signUp` result (UUIDv5 identity id ≠ `sessions`-row UUIDv7; reads `user_sessions` while `signIn` writes `sessions`). Treat sessions-revoke as backend-pending; don't hand-craft a session id. | +| `revokeApiKey` | **defective** (silent partial write) | GAP-3 | Returns `true` + writes an audit-log entry but never sets `revoked_at` — the key keeps working. Don't trust its `true` as a revoke (security footgun). | +| `sendVerificationEmail` | **defective** (aborts) | GAP-9 | Aborts before any email enqueues (`user_secrets_del(uuid, text[]) does not exist`). Email-verification unreachable on `auth:email`; the send raises server-side. No workaround. | +| `sendAccountDeletionEmail` | **defective** (silent no-op) | GAP-10 | Returns HTTP 200 but enqueues nothing — the UI claims "a confirmation email has been sent" while Mailpit stays empty. Don't hand-roll the deletion email. | +| `forgotPassword` / `signOut` | **defective** (empty selection) | GAP-11 | `forgot-password-card` + `sign-out-button` (dashboard-blocks) ship `selection:{fields:{}}` which codegen rejects (`… must have a selection of subfields`). App-local fix: set the selection to `{ clientMutationId: true }`. (`signOut` codegen is also broken per GAP-4.) | + +A contract advisory is **not** a failure — the block is installable and compiles. It is a heads-up so the build doesn't burn a round-trip on a runtime arg-domain error or a silent no-op. (GAP-5 absent ops live in table **(A)**, handled by the binding gate's pending mechanism — they are intentionally **not** duplicated as contract advisories.) + +## The override seam (portability) + +The default path is the generated hook. Every data block also accepts an `onSubmit` (mutations) / `adapter` (queries) prop that **fully replaces** the network call, so the block runs on a non-Constructive backend. The block keeps owning form state, validation, error mapping, and notifications either way: + +```tsx + myAuth.login(vars)} onSuccess={(r) => ...} /> +``` + +This is the one soft point in the binding; everything else is the canonical Constructive-stack path. + +## Testing blocks + +Generated SDK hooks (`useMutation`, `useQuery`) bind to a **module-level client singleton** — there is no client prop and no network call a prop can intercept. A test replaces the data layer, not the network. In order of preference: + +1. **Use the override seam (no mocking).** Pass the block's `onSubmit` (mutations) / `adapter` (queries) prop a fake resolver and assert on form state / `onSuccess`: + ```tsx + render( ({ accessToken: 'tok' })} onSuccess={onSuccess} />); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + expect(onSuccess).toHaveBeenCalled(); + ``` +2. **Mock the `@/generated/` module** to exercise the default hook path without touching the singleton (`jest.mock`; Vitest `vi.mock` is equivalent): + ```tsx + jest.mock('@/generated/auth', () => ({ + useSignInMutation: () => ({ mutateAsync: jest.fn().mockResolvedValue({ signIn: { result: { accessToken: 'tok' } } }), isPending: false }), + configure: jest.fn(), + })); + ``` +3. **Mount `` (integration).** For the real hook + `QueryClient`, wrap in ` null}>` and point `NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT` at a mock server (e.g. MSW). Slower — reserve for a few integration tests. + +Always wrap rendered components that read React Query state in a `QueryClientProvider` (or ``, which provides one). Never leave a real generated module unmocked in a unit test — it reads a `NEXT_PUBLIC_*` endpoint that isn't set and fails opaquely. + +## Authoring a new block — checklist + +A new block is contract-compliant only if all hold (full list in `references/binding-doctrine.md` §11): + +1. Data blocks import hooks from `@/generated/` — never a package name or hardcoded generated path. +2. No `fetch`, no GraphQL document strings, no `configure()`/`getClient()`, no `QueryClientProvider` in any block file. +3. Calls use the real generated hook names and pass a `selection`. +4. An `onSubmit`/`adapter` override prop is present and fully replaces the default hook. +5. Co-located `.requires.json` lists namespace + ops; presentational blocks ship none. +6. `blocks-runtime` is in the block's `registryDependencies`; the block mounts no provider. +7. The registry `docs` field summarizes the SDK/proc prerequisites for humans. +8. `grep` for `@constructive-io/data`, `useConstructiveClient`, ``, `tokenStorage` finds nothing. + +UI is built on `@constructive-io/ui` (consumed as an npm dependency — **never** vendored/copied) + the shared foundation libs/primitives (`auth-errors`, `auth-schemas`, `form-field`, `auth-error-alert`, `auth-loading-button`). Form state uses `@tanstack/react-form`. + +## Reference Guide + +| Reference | Topic | Consult when | +|---|---|---| +| [binding-doctrine.md](./references/binding-doctrine.md) | The canonical SDK binding law: namespaces, import convention, runtime, hook anatomy, override seam, compliance checklist | Authoring a block, reviewing one, or resolving any "how does a block reach the backend" question | +| [manifest-and-checks.md](./references/manifest-and-checks.md) | Authoritative `requires.json` schema (single + cross-namespace), op-name rules, `check-sdk.mjs` invocation/exit codes/remediation | Writing or validating a manifest, interpreting a check failure | +| [flow-catalog.md](./references/flow-catalog.md) | The GA flow catalog (human-readable) — each flow's preset, resolved modules, exposed ops, and blocks. Machine twin: [`flows.json`](./references/flows.json) | Picking which flow(s) to install, deciding the modules to provision and the blocks to add (see "Flow selection") | + +## Cross-References + +- `constructive-codegen` / `constructive-hooks` / `constructive-orm` / `constructive-search` — generating the SDK this skill consumes: `cnc codegen` flags, hook/ORM output shapes, selection/pagination/search. +- `constructive-frontend` — the `@constructive-io/ui` component library blocks are built on, **and** the home of domain-entity CRUD UI (CRUD Stack + `_meta` meta-forms, scaffolded by `scaffold-frontend.mjs`). Reach for it for business-table UI; reach for blocks for auth/account/org/shell. +- `constructive-platform` — CNC CLI, server config, API/endpoint deployment (what determines which ops a namespace exposes). diff --git a/.agents/skills/constructive-blocks/references/binding-doctrine.md b/.agents/skills/constructive-blocks/references/binding-doctrine.md new file mode 100644 index 0000000..a28c976 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/binding-doctrine.md @@ -0,0 +1,114 @@ +# Binding Doctrine + +Condensation of the canonical **SDK Binding Contract** for in-skill use. Where any older blocks doc disagrees about data fetching, hooks, clients, providers, or endpoints, this wins. It supersedes the `@constructive-io/data` hybrid, the `` model, and any pinned-SDK frame. + +## 0. The doctrine + +A block binds to the **per-application generated SDK** — the namespaced TypeScript client the *host app* produces with `@constructive-io/graphql-codegen` from *its own* PostGraphile endpoints — **not** to any pinned, hand-written, or pre-published SDK package. It imports generated **React Query hooks** from a convention path (`@/generated/`) the host has aliased to its generated output. The block ships no network code of its own. + +## 1. Why per-app, not pinned + +A Constructive app's GraphQL surface is **dynamic** — a function of which pgpm modules are deployed, the app's `api_schemas` config, and `database_settings` flags. Two apps almost never expose the same operations, types, or field sets. A block pinned to one frozen `.d.ts` is correct for exactly one app and silently wrong for every other (the prior build's failure mode: guessed op names, wrong arg wrappers, wrong payload shapes). Codegen against the host's *live* endpoints encodes the exact operation kind, input shape, payload wrapper, and field names — a block written against the generated signatures is correct by construction. + +## 2. Namespaces + +Codegen emits one SDK per registered API (a row in `services_public.apis`; its `api_schemas` list the PostgreSQL schemas it exposes; each is reachable at its own subdomain). The four standard namespaces: + +| Namespace | Subdomain | Schema set (current) | +|---|---|---| +| `auth` | `auth.` | `constructive_auth_public` + `users_public` + `user_identifiers_public` + `logging_public` | +| `admin` | `admin.` | `memberships_public` + `permissions_public` + `limits_public` + `invites_public` + `status_public` | +| `objects` | `objects.` | `object_store_public` + `object_tree_public` | +| `public` | `api.` | nearly all of the above combined | + +**Routing blocks to a namespace:** + +- Auth flows (sign-in, password, email/MFA, account, identity) → `auth`. +- Membership / invite / role / permission / limit / status → `admin`. (Invite *acceptance* mutations `submitAppInviteCode` / `submitOrgInviteCode` live in `invites_public`, reachable via `admin` or `public`.) +- File/object blocks → `objects`. +- A block needing ops from more than one schema set targets `public`, **or** imports from two namespaces. Prefer a single namespace per block; document any cross-namespace block in `requires.json` with multiple entries. The list is not closed — an app may register custom APIs. + +## 3. Import convention (locked v1) + +```tsx +'use client'; +import { useSignInMutation } from '@/generated/auth'; +import { useOrganizationMembersQuery } from '@/generated/admin'; +``` + +A block **never** imports from a versioned SDK package name, never hardcodes a path like `src/graphql/auth-sdk/api`, and never writes its own `fetch`, GraphQL document, or client bootstrap. + +> **Why a convention path, not an injected client?** Generated hooks are hard-bound to a module-level singleton (`getClient()`) — there is no `client` parameter on any hook. The only way a block and the host share one configured client is to import the *same generated module*. The `@/generated/` alias makes "the same module" a stable, app-agnostic name a block compiles against. + +## 4. The override seam (portability) + +The default path is the generated hook. Every block also accepts `onSubmit` (mutations) / `adapter` (queries) that **fully replaces** the network call, so the block stays usable on a non-Constructive backend. The block still owns form state, validation, error mapping, and notifications regardless. This is the one soft point in the binding; everything else here is the canonical path. + +## 5. Generated hook anatomy + +**Naming** (confirmed against real codegen output): + +- Custom operations → `useMutation` (e.g. `useSignInMutation`, `useRequireStepUpMutation`). The previous plan assumed `useSignIn`; the real name is `useSignInMutation`. +- Table reads → `useQuery` / `useQuery` (e.g. `useUsersQuery`, `useUserQuery`). +- Table writes → `useCreateMutation` / `useUpdateMutation` / `useDeleteMutation`. + +**React Query.** Every hook calls `useMutation`/`useQuery` and needs a `QueryClient` in the tree (the runtime supplies it). Each takes a `selection` field-picker plus standard React Query options: + +```tsx +const signIn = useSignInMutation({ + selection: { fields: { result: { select: { userId: true, mfaRequired: true } } } }, + onSuccess: (data) => { /* data.signIn... */ }, +}); +await signIn.mutateAsync({ email, password, rememberMe }); +``` + +**Per-namespace singleton.** Each SDK ships its own `configure(config)` / `getClient()` backed by a module-level instance. `configure()` must run **once per namespace** (auth and admin are separate singletons). There is **no** `client` prop on any hook. `OrmClientConfig = { endpoint?, headers?, fetch?, adapter?, realtime? }` — there is **no token-storage property**; auth is attached via `headers`/`fetch`/`adapter` (the runtime uses a `getToken`-driven adapter). + +**Model accessor exists iff a `*Connection` type exists.** Codegen infers a table model accessor (`.findMany()` + the `useQuery` hook) only when the SDL has a `*Connection` object type for that table. Tables exposed only as private-schema views get no accessor and no list hook — only their explicit mutations. + +**Op-shape branching** (how a block calls a hook): + +- scalar / Connection return → flat-arg, no `select`, raw return. +- object payload return → `{ input }` + `{ select }`, read `.result`. +- table CRUD → `{ where, data }` with a `*Patch` data type (gated on a valid PK). + +Always verify the real signature in the generated `.d.ts` / hook file — never guess. + +## 6. The runtime block: `blocks-runtime` + +One shipped registry item encapsulating host wiring so no human hand-writes provider boilerplate. It is a `registryDependency` of every data block and mounts, once at app root: + +1. **One** `` (one shared `QueryClient` for all namespaces — the "two QueryClients" fear was an *unmounted-provider* artifact, not a real defect). +2. **Per-namespace `configure()`** for each namespace present, reading `NEXT_PUBLIC__GRAPHQL_ENDPOINT` and attaching auth via a host `getToken` → `Authorization: Bearer ` adapter. + +```tsx + tokenManager.getAccessToken()}> + {children} + +``` + +A block **never** mounts a provider or calls `configure()`. Tests mount the runtime (or mock the generated hook module) — never react-query directly. + +## 7. Generating the SDK (`cnc codegen`) + +```bash +cnc codegen --endpoint https://auth./graphql --react-query --orm -o src/generated/auth +cnc codegen --api-names auth,admin,public,objects --react-query --orm -o src/generated +cnc codegen --schema-file ./schemas/auth.graphql --react-query --orm -o src/generated/auth +``` + +`--react-query` **and** `--orm` are both required. `--dry-run` previews without writing (used by the staleness check). Sources are mutually exclusive: `--endpoint` | `--schema-file` | `--schema-dir` | `--api-names`/`--schemas` | `--config`. Output is never hand-edited (`@generated … DO NOT EDIT`); regeneration is the only correct change. + +## 11. Compliance checklist + +A reviewer checking a block MUST confirm: + +1. **Generated-hook import** — data blocks import from `@/generated/`, never a package name or hardcoded generated path. +2. **No network code** — no `fetch`, no GraphQL document strings, no `configure()`/`getClient()`, no `QueryClientProvider` in any block file. +3. **Generated hook names** — calls use real generated names (`useMutation`, `useQuery`) and pass a `selection`. +4. **Override seam** — `onSubmit`/`adapter` present and fully replaces the default hook. +5. **`requires.json`** — every data block ships a co-located manifest; presentational blocks ship none. +6. **Runtime dependency** — data blocks list `blocks-runtime` in `registryDependencies`; none mount a provider. +7. **Docs prerequisite** — the registry `docs` field summarizes SDK/proc prerequisites for humans. +8. **Gap honesty** — blocks for known gaps carry the out-of-scope / backend-pending banner; their `requires.json` names the absent op. +9. **No pinned-SDK references** — `grep` for `@constructive-io/data`, `@constructive-io/react`, `useConstructiveClient`, ``, `tokenStorage` finds nothing in block source. diff --git a/.agents/skills/constructive-blocks/references/flow-catalog.md b/.agents/skills/constructive-blocks/references/flow-catalog.md new file mode 100644 index 0000000..58619b1 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/flow-catalog.md @@ -0,0 +1,309 @@ + + +# Flow catalog + +Source of truth: `apps/blocks/scripts/flows-content.mjs`. sotHash: `e0e943259833ed66bc1649ebbccc0fce8e93d9e617607f3547849b4fc8853d22`. + +Each flow is a backend-capability bundle: a preset to provision (resolved to a flat module list), the GraphQL operations it exposes, and the Blocks that wire the UI. GA-only. + +**Scope:** Flows are auth, account, and organization capability bundles — the identity/membership surface. They are NOT general app flows and do NOT cover your domain data UI. For YOUR business-entity screens (list/create/edit/delete of your tables), build domain UI from the data model with constructive-frontend (CRUD Stack + _meta meta-forms) — automated by the harness’s scripts/scaffold-frontend.mjs (Phase 4). + +## Authentication + +### Email + password (`email-password`) + +The reference sign-in surface: register, sign in, sign out, and read the current user against email + password credentials. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `signUp`, `signIn`, `signOut`, `currentUser` +- **Blocks:** `auth-sign-in-card`, `auth-sign-up-card`, `auth-sign-out-button`, `auth-sign-in-page`, `auth-sign-up-page` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-sign-in-card @constructive/auth-sign-up-card @constructive/auth-sign-out-button @constructive/auth-sign-in-page @constructive/auth-sign-up-page +``` + +### Email verification (`email-verification`) + +Confirm a user owns their email: a verify-link landing page plus a resend banner for unverified accounts. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `verifyEmail`, `sendVerificationEmail` +- **Blocks:** `auth-verify-email-banner`, `auth-verify-email-page` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-verify-email-banner @constructive/auth-verify-email-page +``` + +### Password reset (`password-reset`) + +Forgot-password request plus the emailed reset-token landing — enumeration-safe by construction. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `forgotPassword`, `resetPassword` +- **Blocks:** `auth-forgot-password-card`, `auth-forgot-password-page`, `auth-reset-password-card`, `auth-reset-password-page` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-forgot-password-card @constructive/auth-forgot-password-page @constructive/auth-reset-password-card @constructive/auth-reset-password-page +``` + +### Social / OAuth sign-in (`social-oauth`) + +Sign in with configured identity providers (Google, GitHub, …) rendered as a button row or a prominent grid. + +- **Preset:** `auth:sso` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `connected_accounts_module`, `identity_providers_module` +- **Exposed ops:** `identityProviders`, `signInIdentity`, `signUpIdentity` +- **Blocks:** `auth-social-buttons`, `auth-social-providers-grid` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-social-buttons @constructive/auth-social-providers-grid +``` + +### Cross-origin sign-in (`cross-origin`) + +Hand an authenticated session to another origin via a short-lived one-time token appended to the destination URL. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `requestCrossOriginToken`, `signInCrossOrigin` +- **Blocks:** `auth-cross-origin-link` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-cross-origin-link +``` + +## Account & session + +### Profile (`profile`) + +Let the signed-in user edit their display name and avatar against the auth:email user model. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `updateUser`, `currentUser` +- **Blocks:** `auth-account-profile-card` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-profile-card +``` + +### Account emails (`account-emails`) + +Manage the signed-in user's email addresses: add, verify, set primary, and remove. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `createEmail`, `updateEmail`, `deleteEmail`, `emails` +- **Blocks:** `auth-account-emails-list` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-emails-list +``` + +### Change password (`change-password`) + +An authenticated, step-up-gated form to set a new password with an inline strength meter. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `setPassword`, `checkPassword` +- **Blocks:** `auth-change-password-form` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-change-password-form +``` + +### Sessions (`sessions`) + +List the user's active sessions and revoke them individually or in bulk, gated behind step-up. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `revokeSession`, `extendTokenExpires` +- **Blocks:** `auth-account-sessions-list` +- **Contract:** Single revoke is step-up tier=medium; revoke-all-others is tier=high — both must complete a step-up before the mutation fires. +- **Contract:** No generated list hook for sessions — supply rows via the `sessions` prop; the block lists but does not fetch. +- **Known backend limitation:** revokeSession is uncallable from the auth result: the id on a signUp/signIn result is a UUIDv5 identity/credential id, not the UUIDv7 sessions.id, and no field exposes the real session id — so revokeSession(authResult.id) returns SESSION_NOT_FOUND. Ship revoke-current-session as backend-pending; do NOT hand-craft a session id or fall back to SQL. (PLATFORM-GAPS GAP-2) + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-sessions-list +``` + +### API keys (`api-keys`) + +Create and revoke user-scoped API keys, with a one-time reveal modal and step-up on create. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `createApiKey`, `revokeApiKey` +- **Blocks:** `auth-account-api-keys-list`, `auth-api-key-create-dialog`, `auth-api-key-created-modal` +- **Contract:** createApiKey accessLevel accepts ONLY { 'read_only', 'full_access' } — any other value (read/write/admin, required) fails with INVALID_ACCESS_LEVEL at runtime; the auth-api-key-create-dialog block ships an accessLevelOptions list (read/write/admin) that does NOT match the deployed proc, so constrain the UI to the two valid values. +- **Contract:** createApiKey enforces STEP_UP_REQUIRED server-side: a verifyPassword on the SAME session must precede the create (defense-in-depth beyond the client gate). The dialog runs that step-up first; a direct createApiKey call must complete step-up before the mutation. +- **Known backend limitation:** revokeApiKey returns true and writes an audit-log entry but never sets revoked_at — the key keeps working. Treat its true as a no-op, not proof of revocation; do not surface "revoked" as terminal state. (PLATFORM-GAPS GAP-3) + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-api-keys-list @constructive/auth-api-key-create-dialog @constructive/auth-api-key-created-modal +``` + +### Account deletion (`account-deletion`) + +A danger-zone card that emails a deletion confirmation, plus the page that completes the deletion from the link. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `sendAccountDeletionEmail`, `confirmDeleteAccount` +- **Blocks:** `auth-account-danger-card`, `auth-account-deletion-confirm-page` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-danger-card @constructive/auth-account-deletion-confirm-page +``` + +### Step-up verification (`step-up`) + +Re-verify identity (password or TOTP) before a sensitive action, as a dialog or an imperative hook. + +- **Preset:** `auth:email` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module` +- **Exposed ops:** `requireStepUp`, `verifyPassword`, `verifyTotp` +- **Blocks:** `auth-step-up-dialog`, `use-step-up` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-step-up-dialog @constructive/use-step-up +``` + +### Connected accounts (`connected-accounts`) + +List linked OAuth providers and disconnect them (step-up gated); offer connect links for the rest. + +- **Preset:** `auth:sso` +- **Modules:** `users_module`, `membership_types_module`, `permissions_module:app`, `limits_module:app`, `levels_module:app`, `memberships_module:app`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `connected_accounts_module`, `identity_providers_module` +- **Exposed ops:** `disconnectAccount` +- **Blocks:** `auth-account-connected-accounts` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/auth-account-connected-accounts +``` + +## Authorization + +### Organizations (`organization`) + +Create and configure organizations — first-class User records (type=2) in the unified user model. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `createUser`, `updateUser`, `currentUser` +- **Blocks:** `org-create-card`, `org-settings-form` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-create-card @constructive/org-settings-form +``` + +### Org members (`org-members`) + +List an organization's members with inline role changes and step-up-gated removal. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `orgMemberships`, `updateOrgMembership`, `deleteOrgMembership` +- **Blocks:** `org-members-list` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-members-list +``` + +### Org roles (`org-roles`) + +Create, edit, and delete named org role profiles that bundle the org-scoped permission set. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `createOrgProfile`, `updateOrgProfile`, `deleteOrgProfile` +- **Blocks:** `org-create-card`, `org-roles-editor` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-create-card @constructive/org-roles-editor +``` + +### Org invites (`org-invites`) + +Invite members to an org by email and let invitees accept app- or org-level invitations from a token link. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `createOrgInvite`, `orgInvites`, `submitOrgInviteCode`, `submitAppInviteCode` +- **Blocks:** `org-invite-dialog`, `auth-invitation-acceptance-card`, `auth-invitation-acceptance-page` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-invite-dialog @constructive/auth-invitation-acceptance-card @constructive/auth-invitation-acceptance-page +``` + +### App memberships (`app-memberships`) + +Admin-manage an org's app-level memberships: approve, revoke (step-up gated), and update profiles. + +- **Preset:** `b2b` +- **Modules:** `users_module`, `membership_types_module`, `memberships_module:app`, `memberships_module:org`, `sessions_module`, `user_state_module`, `user_credentials_module`, `config_secrets_module`, `emails_module`, `rls_module`, `user_auth_module`, `session_secrets_module`, `rate_limits_module`, `connected_accounts_module`, `identity_providers_module`, `webauthn_credentials_module`, `webauthn_auth_module`, `phone_numbers_module`, `permissions_module:app`, `permissions_module:org`, `limits_module:app`, `limits_module:org`, `levels_module:app`, `levels_module:org`, `profiles_module:app`, `profiles_module:org`, `hierarchy_module:org`, `invites_module:app`, `invites_module:org`, `devices_module` +- **Exposed ops:** `updateAppMembership`, `deleteAppMembership`, `appMemberships` +- **Blocks:** `org-app-memberships` + +Install: + +```bash +# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh +npx shadcn@latest add @constructive/org-app-memberships +``` diff --git a/.agents/skills/constructive-blocks/references/flows.json b/.agents/skills/constructive-blocks/references/flows.json new file mode 100644 index 0000000..20b82b0 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/flows.json @@ -0,0 +1,1507 @@ +{ + "generatedAt": null, + "source": "apps/blocks/scripts/flows-content.mjs", + "sotHash": "e0e943259833ed66bc1649ebbccc0fce8e93d9e617607f3547849b4fc8853d22", + "groups": [ + { + "id": "authentication", + "label": "Authentication" + }, + { + "id": "account-session", + "label": "Account & session" + }, + { + "id": "authorization", + "label": "Authorization" + } + ], + "flows": [ + { + "id": "email-password", + "name": "Email + password", + "group": "authentication", + "status": "ga", + "summary": "The reference sign-in surface: register, sign in, sign out, and read the current user against email + password credentials.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "signUp", + "signIn", + "signOut", + "currentUser" + ] + }, + "blocks": [ + "auth-sign-in-card", + "auth-sign-up-card", + "auth-sign-out-button", + "auth-sign-in-page", + "auth-sign-up-page" + ], + "howto": { + "provision": "# Provision the auth:email modules onto your database (see Backend below for the full list).\npgpm install # or: provision via databaseProvisionModule.create({ data: { modules } })", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-sign-in-card @constructive/auth-sign-up-card @constructive/auth-sign-out-button @constructive/auth-sign-in-page @constructive/auth-sign-up-page", + "wire": "import { BlocksRuntime } from '@/blocks/runtime/blocks-runtime';\nimport { tokenManager } from '@/lib/auth/token-manager';\n\n// Mount once at the app root so every auth block resolves its hook.\n tokenManager.getAccessToken()}>\n {children}\n", + "usage": "import { SignInCard } from '@/blocks/auth/sign-in-card/sign-in-card';\n\nexport function SignInRoute() {\n const router = useRouter();\n return (\n router.push(\"/\")}\n />\n );\n}" + }, + "relatedFlows": [ + "email-verification", + "password-reset", + "social-oauth", + "profile" + ] + }, + { + "id": "email-verification", + "name": "Email verification", + "group": "authentication", + "status": "ga", + "summary": "Confirm a user owns their email: a verify-link landing page plus a resend banner for unverified accounts.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "verifyEmail", + "sendVerificationEmail" + ] + }, + "blocks": [ + "auth-verify-email-banner", + "auth-verify-email-page" + ], + "howto": { + "provision": "# auth:email already exposes verifyEmail / sendVerificationEmail — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-verify-email-banner @constructive/auth-verify-email-page", + "wire": "import { VerifyEmailBanner } from '@/blocks/auth/verify-email-banner/verify-email-banner';\n\n// Show the banner in your app header for signed-in, unverified users.\nexport function AppHeader({ user }) {\n if (user.isVerified) return null;\n return ;\n}", + "usage": "// Mount the page at /auth/verify-email; it reads ?email_id= and ?token= from the URL.\nimport { VerifyEmailPage } from '@/blocks/auth/verify-email-page/verify-email-page';\n\nexport default function Page() {\n return ;\n}" + }, + "relatedFlows": [ + "email-password", + "password-reset" + ] + }, + { + "id": "password-reset", + "name": "Password reset", + "group": "authentication", + "status": "ga", + "summary": "Forgot-password request plus the emailed reset-token landing — enumeration-safe by construction.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "forgotPassword", + "resetPassword" + ] + }, + "blocks": [ + "auth-forgot-password-card", + "auth-forgot-password-page", + "auth-reset-password-card", + "auth-reset-password-page" + ], + "howto": { + "provision": "# forgotPassword / resetPassword ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-forgot-password-card @constructive/auth-forgot-password-page @constructive/auth-reset-password-card @constructive/auth-reset-password-page", + "wire": "// Both pages are thin route wrappers — no extra wiring beyond BlocksRuntime.\n// forgot-password-page reads ?email=; reset-password-page reads ?token= and ?role_id=.", + "usage": "import ForgotPasswordPage from '@/blocks/auth/forgot-password-page/forgot-password-page';\n\n// app/auth/forgot-password/page.tsx\nexport default function Page() {\n return ;\n}" + }, + "relatedFlows": [ + "email-password", + "change-password" + ] + }, + { + "id": "social-oauth", + "name": "Social / OAuth sign-in", + "group": "authentication", + "status": "ga", + "summary": "Sign in with configured identity providers (Google, GitHub, …) rendered as a button row or a prominent grid.", + "backend": { + "preset": "auth:sso", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "connected_accounts_module", + "identity_providers_module" + ], + "exposedOps": [ + "identityProviders", + "signInIdentity", + "signUpIdentity" + ] + }, + "blocks": [ + "auth-social-buttons", + "auth-social-providers-grid" + ], + "howto": { + "provision": "# auth:sso adds connected_accounts_module + identity_providers_module (see Backend below).\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-social-buttons @constructive/auth-social-providers-grid", + "wire": "import { AuthSocialButtons } from '@/blocks/auth/social-buttons/social-buttons';\n\n// Omit `providers` to load enabled providers from the identity-providers API at runtime.\n", + "usage": "import { AuthSocialProvidersGrid } from '@/blocks/auth/social-providers-grid/social-providers-grid';\n\nexport function SignInExtras() {\n return ;\n}" + }, + "relatedFlows": [ + "email-password", + "connected-accounts" + ] + }, + { + "id": "cross-origin", + "name": "Cross-origin sign-in", + "group": "authentication", + "status": "ga", + "summary": "Hand an authenticated session to another origin via a short-lived one-time token appended to the destination URL.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "requestCrossOriginToken", + "signInCrossOrigin" + ] + }, + "blocks": [ + "auth-cross-origin-link" + ], + "howto": { + "provision": "# requestCrossOriginToken / signInCrossOrigin ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-cross-origin-link", + "wire": "import { CrossOriginLink } from '@/blocks/auth/cross-origin-link/cross-origin-link';\n\n// Mount inside the same form that collected email/password.\n\n Continue to app\n", + "usage": "import { CrossOriginLink } from '@/blocks/auth/cross-origin-link/cross-origin-link';\n\n" + }, + "relatedFlows": [ + "email-password" + ] + }, + { + "id": "profile", + "name": "Profile", + "group": "account-session", + "status": "ga", + "summary": "Let the signed-in user edit their display name and avatar against the auth:email user model.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "updateUser", + "currentUser" + ] + }, + "blocks": [ + "auth-account-profile-card" + ], + "howto": { + "provision": "# updateUser / currentUser ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-profile-card", + "wire": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n// The profile card binds to updateUser/currentUser — only BlocksRuntime is required.\n// NOTE: auth-account-security-card (passkeys) and the auth-account-settings-page\n// composite hard-import ops OUTSIDE auth:email (webauthnCredentials, phoneNumbers/SMS,\n// connectedAccounts/SSO). They belong to a richer preset (auth:sso / b2b), not this\n// minimal profile flow — install them only once those modules are provisioned.\nexport default function AccountPage() {\n return ;\n}", + "usage": "import { AccountProfileCard } from '@/blocks/auth/account-profile-card/account-profile-card';\n\n toast.success(\"Profile updated\")} />" + }, + "relatedFlows": [ + "account-emails", + "change-password", + "sessions" + ] + }, + { + "id": "account-emails", + "name": "Account emails", + "group": "account-session", + "status": "ga", + "summary": "Manage the signed-in user's email addresses: add, verify, set primary, and remove.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "createEmail", + "updateEmail", + "deleteEmail", + "emails" + ] + }, + "blocks": [ + "auth-account-emails-list" + ], + "howto": { + "provision": "# emails_module + its CRUD ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-emails-list", + "wire": "import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list';\n\n", + "usage": "import { AccountEmailsList } from '@/blocks/auth/account-emails-list/account-emails-list';\n\n" + }, + "relatedFlows": [ + "profile", + "email-verification" + ] + }, + { + "id": "change-password", + "name": "Change password", + "group": "account-session", + "status": "ga", + "summary": "An authenticated, step-up-gated form to set a new password with an inline strength meter.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "setPassword", + "checkPassword" + ] + }, + "blocks": [ + "auth-change-password-form" + ], + "howto": { + "provision": "# setPassword / checkPassword ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-change-password-form", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// The form runs a step-up re-verification, so a StepUpProvider must be an ancestor.\n{children}", + "usage": "import { ChangePasswordForm } from '@/blocks/auth/change-password-form/change-password-form';\n\n toast(\"Password updated\")} />" + }, + "relatedFlows": [ + "password-reset", + "step-up", + "profile" + ] + }, + { + "id": "sessions", + "name": "Sessions", + "group": "account-session", + "status": "ga", + "summary": "List the user's active sessions and revoke them individually or in bulk, gated behind step-up.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "revokeSession", + "extendTokenExpires" + ] + }, + "blocks": [ + "auth-account-sessions-list" + ], + "howto": { + "provision": "# sessions_module + revokeSession ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-sessions-list", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Single revoke is step-up tier=medium; revoke-all-others is tier=high.\n{children}", + "usage": "import { AccountSessionsList } from '@/blocks/auth/account-sessions-list/account-sessions-list';\n\n// The session list has no generated list hook — supply rows via the `sessions` prop.\n" + }, + "contract": { + "constraints": [ + "Single revoke is step-up tier=medium; revoke-all-others is tier=high — both must complete a step-up before the mutation fires.", + "No generated list hook for sessions — supply rows via the `sessions` prop; the block lists but does not fetch." + ], + "knownBackendLimitations": [ + "revokeSession is uncallable from the auth result: the id on a signUp/signIn result is a UUIDv5 identity/credential id, not the UUIDv7 sessions.id, and no field exposes the real session id — so revokeSession(authResult.id) returns SESSION_NOT_FOUND. Ship revoke-current-session as backend-pending; do NOT hand-craft a session id or fall back to SQL. (PLATFORM-GAPS GAP-2)" + ] + }, + "relatedFlows": [ + "step-up", + "profile" + ] + }, + { + "id": "api-keys", + "name": "API keys", + "group": "account-session", + "status": "ga", + "summary": "Create and revoke user-scoped API keys, with a one-time reveal modal and step-up on create.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "createApiKey", + "revokeApiKey" + ] + }, + "blocks": [ + "auth-account-api-keys-list", + "auth-api-key-create-dialog", + "auth-api-key-created-modal" + ], + "howto": { + "provision": "# API-key CRUD ships with auth:email (user_auth_module) — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-api-keys-list @constructive/auth-api-key-create-dialog @constructive/auth-api-key-created-modal", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Create is gated behind a high-severity step-up; mount the provider once.\n// The deployed create_api_key proc accepts ONLY:\n// accessLevel ∈ { 'read_only', 'full_access' } mfaLevel ∈ { 'none', 'verified' }\n// Any other value (read/write/admin, required) -> INVALID_ACCESS_LEVEL at runtime.\n// createApiKey also enforces STEP_UP_REQUIRED server-side: a verifyPassword on the\n// SAME session must precede the create. The dialog runs that step-up first; if you\n// call createApiKey directly, complete step-up (verifyPassword) before the mutation.\n{children}", + "usage": "import { AccountApiKeysList } from '@/blocks/auth/account-api-keys-list/account-api-keys-list';\n\n// No generated list hook for user_api_keys — supply rows via the `keys` prop.\n// Valid create inputs: accessLevel 'read_only' | 'full_access'; mfaLevel 'none' | 'verified'.\n" + }, + "contract": { + "constraints": [ + "createApiKey accessLevel accepts ONLY { 'read_only', 'full_access' } — any other value (read/write/admin, required) fails with INVALID_ACCESS_LEVEL at runtime; the auth-api-key-create-dialog block ships an accessLevelOptions list (read/write/admin) that does NOT match the deployed proc, so constrain the UI to the two valid values.", + "createApiKey enforces STEP_UP_REQUIRED server-side: a verifyPassword on the SAME session must precede the create (defense-in-depth beyond the client gate). The dialog runs that step-up first; a direct createApiKey call must complete step-up before the mutation." + ], + "knownBackendLimitations": [ + "revokeApiKey returns true and writes an audit-log entry but never sets revoked_at — the key keeps working. Treat its true as a no-op, not proof of revocation; do not surface \"revoked\" as terminal state. (PLATFORM-GAPS GAP-3)" + ] + }, + "relatedFlows": [ + "step-up", + "profile" + ] + }, + { + "id": "account-deletion", + "name": "Account deletion", + "group": "account-session", + "status": "ga", + "summary": "A danger-zone card that emails a deletion confirmation, plus the page that completes the deletion from the link.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "sendAccountDeletionEmail", + "confirmDeleteAccount" + ] + }, + "blocks": [ + "auth-account-danger-card", + "auth-account-deletion-confirm-page" + ], + "howto": { + "provision": "# delete_account flow ships with auth:email (user_auth_module) — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-danger-card @constructive/auth-account-deletion-confirm-page", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// The danger card gates the deletion email behind a high-tier step-up.\n{children}", + "usage": "import { AccountDeletionConfirmPage } from '@/blocks/auth/account-deletion-confirm-page/account-deletion-confirm-page';\n\n// app/auth/delete-account/page.tsx — reads ?token= and ?user_id= from the link.\n" + }, + "relatedFlows": [ + "step-up", + "profile" + ] + }, + { + "id": "step-up", + "name": "Step-up verification", + "group": "account-session", + "status": "ga", + "summary": "Re-verify identity (password or TOTP) before a sensitive action, as a dialog or an imperative hook.", + "backend": { + "preset": "auth:email", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module" + ], + "exposedOps": [ + "requireStepUp", + "verifyPassword", + "verifyTotp" + ] + }, + "blocks": [ + "auth-step-up-dialog", + "use-step-up" + ], + "howto": { + "provision": "# requireStepUp / verifyPassword ship with auth:email — no extra modules.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-step-up-dialog @constructive/use-step-up", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Mount the provider once near the app root; consumers call useStepUp() below it.\n// Step-up resolves a verifyPassword/verifyTotp on the CURRENT session — server-side\n// gated ops (e.g. createApiKey enforces STEP_UP_REQUIRED) must be preceded by it.\n{children}", + "usage": "import { useStepUp, StepUpError } from '@/blocks/auth/use-step-up/use-step-up';\n\nasync function onDangerousAction() {\n const stepUp = useStepUp();\n try {\n await stepUp({ tier: 'high' });\n await deleteAccount();\n } catch (err) {\n if (err instanceof StepUpError && err.reason === 'cancelled') return;\n throw err;\n }\n}" + }, + "relatedFlows": [ + "change-password", + "sessions", + "api-keys", + "account-deletion" + ] + }, + { + "id": "connected-accounts", + "name": "Connected accounts", + "group": "account-session", + "status": "ga", + "summary": "List linked OAuth providers and disconnect them (step-up gated); offer connect links for the rest.", + "backend": { + "preset": "auth:sso", + "modules": [ + "users_module", + "membership_types_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "app" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "connected_accounts_module", + "identity_providers_module" + ], + "exposedOps": [ + "disconnectAccount" + ] + }, + "blocks": [ + "auth-account-connected-accounts" + ], + "howto": { + "provision": "# disconnectAccount + connected_accounts_module ship with auth:sso (see Backend below).\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/auth-account-connected-accounts", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Disconnect is gated behind a step-up (tier: medium).\n{children}", + "usage": "import { AccountConnectedAccounts } from '@/blocks/auth/account-connected-accounts/account-connected-accounts';\n\n// Connection types are not yet public — pass connectedAccounts + providers as props.\n" + }, + "relatedFlows": [ + "social-oauth", + "step-up" + ] + }, + { + "id": "organization", + "name": "Organizations", + "group": "authorization", + "status": "ga", + "summary": "Create and configure organizations — first-class User records (type=2) in the unified user model.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], + "devices_module" + ], + "exposedOps": [ + "createUser", + "updateUser", + "currentUser" + ] + }, + "blocks": [ + "org-create-card", + "org-settings-form" + ], + "howto": { + "provision": "# Orgs require the full B2B stack (org-scoped memberships/permissions/invites/hierarchy).\n# There is no preset smaller than b2b for org flows — see Backend below.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-create-card @constructive/org-settings-form", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// org-settings-form gates danger-zone deletion behind a step-up.\n{children}", + "usage": "import { OrgCreateCard } from '@/blocks/org/create-card/create-card';\n\n// Creates a users row with type=2 (an organization).\n router.push(`/orgs/${org.id}`)} />" + }, + "relatedFlows": [ + "org-members", + "org-roles", + "org-invites", + "app-memberships" + ] + }, + { + "id": "org-members", + "name": "Org members", + "group": "authorization", + "status": "ga", + "summary": "List an organization's members with inline role changes and step-up-gated removal.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], + "devices_module" + ], + "exposedOps": [ + "orgMemberships", + "updateOrgMembership", + "deleteOrgMembership" + ] + }, + "blocks": [ + "org-members-list" + ], + "howto": { + "provision": "# Org membership CRUD requires the b2b org-scoped memberships module — see Backend below.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-members-list", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Sensitive member actions require a step-up before the mutation fires.\n{children}", + "usage": "import { MembersList } from '@/blocks/org/members-list/members-list';\n\n// GA path is updateOrgMembership (role change) + deleteOrgMembership (remove).\n// removeOrgMember / transferOrgOwnership are NOT deployed in the provisioned admin\n// schema yet — pending seams; do not call them.\n" + }, + "relatedFlows": [ + "organization", + "org-roles", + "org-invites" + ] + }, + { + "id": "org-roles", + "name": "Org roles", + "group": "authorization", + "status": "ga", + "summary": "Create, edit, and delete named org role profiles that bundle the org-scoped permission set.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], + "devices_module" + ], + "exposedOps": [ + "createOrgProfile", + "updateOrgProfile", + "deleteOrgProfile" + ] + }, + "blocks": [ + "org-create-card", + "org-roles-editor" + ], + "howto": { + "provision": "# Org role profiles require the b2b org-scoped profiles module — see Backend below.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-create-card @constructive/org-roles-editor", + "wire": "// Both blocks bind to the generated admin SDK hooks — only BlocksRuntime is required.", + "usage": "import { OrgCreateCard } from '@/blocks/org/create-card/create-card';\nimport { OrgRolesEditor } from '@/blocks/org/roles-editor/roles-editor';\n\n// OrgRolesEditor needs an orgId (a User row with type=2). Create the org first with\n// org-create-card, then pass its id to the editor.\n setOrgId(org.id)} />\n{orgId && }" + }, + "relatedFlows": [ + "organization", + "org-members" + ] + }, + { + "id": "org-invites", + "name": "Org invites", + "group": "authorization", + "status": "ga", + "summary": "Invite members to an org by email and let invitees accept app- or org-level invitations from a token link.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], + "devices_module" + ], + "exposedOps": [ + "createOrgInvite", + "orgInvites", + "submitOrgInviteCode", + "submitAppInviteCode" + ] + }, + "blocks": [ + "org-invite-dialog", + "auth-invitation-acceptance-card", + "auth-invitation-acceptance-page" + ], + "howto": { + "provision": "# Invite flows require the b2b invites modules (app + org scope) — see Backend below.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-invite-dialog @constructive/auth-invitation-acceptance-card @constructive/auth-invitation-acceptance-page", + "wire": "import { OrgInviteDialog } from '@/blocks/org/invite-dialog/invite-dialog';\n\n// resendOrgInvite is pending — the dialog resends by cancel + re-create.\n", + "usage": "import InvitationAcceptancePage from '@/blocks/auth/invitation-acceptance-page/invitation-acceptance-page';\n\n// app/invite/page.tsx — reads ?token= and ?kind= from the URL.\nexport default function Page() {\n return ;\n}" + }, + "relatedFlows": [ + "organization", + "org-members", + "app-memberships" + ] + }, + { + "id": "app-memberships", + "name": "App memberships", + "group": "authorization", + "status": "ga", + "summary": "Admin-manage an org's app-level memberships: approve, revoke (step-up gated), and update profiles.", + "backend": { + "preset": "b2b", + "modules": [ + "users_module", + "membership_types_module", + [ + "memberships_module", + { + "scope": "app" + } + ], + [ + "memberships_module", + { + "scope": "org" + } + ], + "sessions_module", + "user_state_module", + "user_credentials_module", + "config_secrets_module", + "emails_module", + "rls_module", + "user_auth_module", + "session_secrets_module", + "rate_limits_module", + "connected_accounts_module", + "identity_providers_module", + "webauthn_credentials_module", + "webauthn_auth_module", + "phone_numbers_module", + [ + "permissions_module", + { + "scope": "app" + } + ], + [ + "permissions_module", + { + "scope": "org" + } + ], + [ + "limits_module", + { + "scope": "app" + } + ], + [ + "limits_module", + { + "scope": "org" + } + ], + [ + "levels_module", + { + "scope": "app" + } + ], + [ + "levels_module", + { + "scope": "org" + } + ], + [ + "profiles_module", + { + "scope": "app" + } + ], + [ + "profiles_module", + { + "scope": "org" + } + ], + [ + "hierarchy_module", + { + "scope": "org" + } + ], + [ + "invites_module", + { + "scope": "app" + } + ], + [ + "invites_module", + { + "scope": "org" + } + ], + "devices_module" + ], + "exposedOps": [ + "updateAppMembership", + "deleteAppMembership", + "appMemberships" + ] + }, + "blocks": [ + "org-app-memberships" + ], + "howto": { + "provision": "# App membership management requires the b2b app-scoped memberships module — see Backend below.\npgpm install", + "install": "# first register @constructive against your served registry — see blocks-onramp §4c / run scripts/serve-registry.sh\nnpx shadcn@latest add @constructive/org-app-memberships", + "wire": "import { StepUpProvider } from '@/blocks/auth/use-step-up/step-up-provider';\n\n// Revoke is gated behind a confirmation dialog + step-up (tier: medium).\n{children}", + "usage": "import { OrgAppMemberships } from '@/blocks/org/app-memberships/app-memberships';\n\n" + }, + "relatedFlows": [ + "organization", + "org-invites", + "org-members" + ] + } + ] +} diff --git a/.agents/skills/constructive-blocks/references/manifest-and-checks.md b/.agents/skills/constructive-blocks/references/manifest-and-checks.md new file mode 100644 index 0000000..0a413e0 --- /dev/null +++ b/.agents/skills/constructive-blocks/references/manifest-and-checks.md @@ -0,0 +1,137 @@ +# Manifest & Checks + +The authoritative `.requires.json` schema and the `check-sdk.mjs` preflight. Per the SDK Binding Contract §7, **this document is authoritative** for the manifest shape — where the contract leaves the cross-namespace form to "pick one and keep it consistent," the choice is locked here. + +## What ships a manifest + +Every **data block** (any block importing a generated hook) ships a co-located, machine-readable `.requires.json` as a registry `file`. On install it lands at: + +``` +.constructive/blocks/.requires.json +``` + +This path is **relative to the blocks registry target**. shadcn resolves the target against the host's aliases, so on a standard Next.js `src/` layout the manifest actually lands at `src/.constructive/blocks/.requires.json`; only when the blocks target sits at the project root does it land at the root `.constructive/blocks/`. `check-sdk.mjs` scans **both** locations (and accepts `--manifests-dir` to override), so a manifest under `src/` is never silently missed. + +**Presentational blocks ship none** (no generated-hook import → nothing to verify). The registry item's `docs` field always carries a *human-readable* summary of the same prerequisites; the JSON manifest is the machine-checkable twin that `check-sdk.mjs` reads. + +## Schema — single namespace (canonical) + +A block that imports from one namespace ships a single top-level object: + +```json +{ + "namespace": "auth", + "mutations": ["signIn", "requireStepUp"], + "queries": ["currentUser"], + "models": [] +} +``` + +| Field | Meaning | +|---|---| +| `namespace` | The generated namespace the block imports from (`auth`, `admin`, `objects`, `public`, or a custom API). Exactly the `` in `@/generated/`. | +| `mutations` | GraphQL **operation names** the block calls — camelCase, post-inflection (`signIn`, not `SignIn`, not `useSignInMutation`). The check derives the hook name. | +| `queries` | GraphQL query operation names, same convention. | +| `models` | Table **model accessors** the block needs — populated **only** when the block uses a `useQuery` list hook. Subject to the Connection rule (below). The ORM accessor is **singular** (`db.orgMembership`); prefer the singular name, but the check normalises plural↔singular so either form matches. | +| `pending` *(optional)* | Op/model names this block declares as **backend-pending** — a seam shipped for a proc not yet deployed in any public schema (e.g. `transferOrgOwnership`, `removeOrgMember`). The check **reports** these but never fails on them. A missing op that is **not** listed here still fails clearly. Omit when the block has no pending seam. Accepts a flat array `["transferOrgOwnership"]` or a per-kind object `{ "mutations": [...], "models": [...] }`. | + +The four core keys are present; unused ones are empty arrays. `pending` is optional. + +### Model names are singular-normalised + +A model accessor and its `models/.ts` file are **always singular** (the ORM exposes `db.orgMembership.findMany()`, never `db.orgMemberships`), even though the *list hook* it pairs with is plural (`useOrgMembershipsQuery`). The manifest's `models` entry names the **accessor**, so the canonical form is singular (`orgMembership`, `email`, `user`). `check-sdk.mjs` normalises both the declared name and the on-disk file name through one singulariser, so a manifest that declares `orgMemberships` (plural) and one that declares `orgMembership` (singular) **both** satisfy the same `models/orgMembership.ts` — author either, prefer singular. + +### Declaring a backend-pending seam + +Some GA blocks ship a button/path for a procedure that is real-but-not-yet-deployed (the "pending seams" called out in `flows.json` — `transferOrgOwnership`, `removeOrgMember`, `resendOrgInvite`). Such a block is still **correctly wired**: its GA path stands alone and the pending action degrades gracefully. List the pending op in `pending` so the preflight reports it as informational (`◦ … (backend-pending)`) instead of a hard `✗`: + +```json +{ + "namespace": "admin", + "mutations": ["updateOrgMembership", "deleteOrgMembership", "removeOrgMember", "transferOrgOwnership"], + "queries": [], + "models": ["orgMembership"], + "pending": ["removeOrgMember", "transferOrgOwnership"] +} +``` + +This keeps the check honest: declared-pending ops don't block a build, but any op the SDK lacks that is **not** declared pending still fails — so a genuine wiring/stale-SDK error is never masked. + +## Schema — cross-namespace (locked shape) + +A block that imports from more than one namespace uses a top-level **`requires` array**, one object per namespace: + +```json +{ + "requires": [ + { "namespace": "admin", "mutations": ["submitOrgInviteCode"], "queries": [], "models": [] }, + { "namespace": "auth", "mutations": [], "queries": ["currentUser"], "models": [] } + ] +} +``` + +This is the **one** cross-namespace shape — do not use a bare top-level array, and do not nest namespaces inside a single object. `check-sdk.mjs` normalizes a manifest as: `raw.requires` when present, else `[raw]` (the single-object form). Prefer a single namespace per block where possible (§2); reach for `requires[]` only when a block genuinely spans schema sets. + +## Operation-name → hook-name derivation + +The manifest names **operations**; the check derives the generated **hook** it expects to find exported by the SDK: + +| Manifest field | Entry | Expected SDK export | +|---|---|---| +| `mutations` | `signIn` | `useSignInMutation` | +| `queries` | `currentUser` | `useCurrentUserQuery` | +| `models` | `user` | a model file `models/user.*` (or an export named `user`) | + +So a manifest entry is satisfied when the operation's `use` identifier is a real export of the namespace's generated SDK. + +## The Connection rule (when `models` applies) + +A model accessor and its `useQuery` list hook exist **iff** the SDL has a `*Connection` object type for that table. Tables exposed only as private-schema views (no Connection type) get **no** accessor and **no** list hook — only their explicit mutations. + +Practical consequence: only list `models` a block actually reads via a list hook. Sessions and API keys (`user_sessions` / `user_api_keys`, in `constructive_auth_private`) have no Connection type, so they are **not listable** through any generated SDK — a manifest must not claim them as `models`. The blocks for those lists are out of frontend scope until an API exposes the Connection (see SKILL.md "Known SDK gaps"). + +## `check-sdk.mjs` + +Zero-dependency Node (≥18), bundled at `scripts/check-sdk.mjs`. Run from the host app root. + +```bash +node scripts/check-sdk.mjs # check every installed manifest +node scripts/check-sdk.mjs auth-sign-in-card # one block by name… +node scripts/check-sdk.mjs ./path/to.requires.json # …or by manifest path +node scripts/check-sdk.mjs --project /path/app # check a different project root +node scripts/check-sdk.mjs --manifests-dir DIR # point at a non-standard manifests dir +node scripts/check-sdk.mjs --json # machine-readable report on stdout +node scripts/check-sdk.mjs --help +``` + +Manifests are auto-discovered under **both** `/.constructive/blocks` and `/src/.constructive/blocks` (a block name passed as `[block]` is resolved against both, too). `--manifests-dir` overrides discovery with an explicit directory. + +### What it verifies + +1. The `@/generated/*` alias exists in the host `tsconfig.json` (follows one `extends` level; tolerant of JSONC comments + trailing commas — comment/comma stripping is **string-aware**, so path globs like `"@/*": ["./src/*"]` are never mis-parsed as block comments). +2. The generated dir for each block's namespace exists, resolved **via the alias** (tries `@/generated/`, `@/generated//*`, then `@/generated/*` — never a hardcoded path). +3. Every manifest `mutation`/`query`/`model` maps to a real export of that SDK (it scans every SDK source file, so a leaf `export function useXMutation` is found regardless of barrel re-exports). +4. *(Advisory)* whether `` appears mounted somewhere in the host source. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Every prerequisite satisfied — or nothing to check (no manifests). | +| `1` | A prerequisite is missing (alias, generated dir, or an op/model export). | +| `2` | The check could not run — no `tsconfig.json`, bad args, or an unreadable/unparseable manifest. | + +### What it does NOT do + +It **never runs `cnc codegen`**. Drift detection (`--dry-run`) and generating a missing SDK need an endpoint and operator confirmation, so the script only *detects* and prints the exact command to run. The operator (or the agent following SKILL.md) performs the generation after confirming endpoint/api-names. + +## Reading a failure → remediation + +| Failure | What it means | Remediation | +|---|---|---| +| `✗ @/generated/* alias in tsconfig` | Host never aliased the generated output. | Add `"@/generated/*": ["./src/generated/*"]` to `tsconfig.json` paths, then re-check. | +| `namespace ✗ (unresolved …)` / dir missing | No SDK generated for that namespace. | The script prints `cnc codegen --api-names --react-query --orm -o src/generated`. Confirm endpoint/api-names with the operator, run it, re-check. | +| `✗ mutation → useMutation` (dir exists) | SDK is present but lacks that op — backend hasn't deployed the procedure, or the SDK is stale. | Regenerate; drift-check with `cnc codegen … --dry-run`. If the op is a known backend-pending gap, the block is not buildable until the proc ships. | +| `• not found` | Advisory only — not a hard failure. | Mount `` once at the app root (see SKILL.md host setup step 3). | + +A red op line is the binding working as designed: the block surfaces the exact missing operation *before* compiling against a guess. diff --git a/.agents/skills/constructive-blocks/scripts/check-flows.mjs b/.agents/skills/constructive-blocks/scripts/check-flows.mjs new file mode 100755 index 0000000..8578c84 --- /dev/null +++ b/.agents/skills/constructive-blocks/scripts/check-flows.mjs @@ -0,0 +1,549 @@ +#!/usr/bin/env node +/** + * check-flows.mjs — drift guard for the Constructive Blocks flow catalog. + * + * Part of the `constructive-blocks` agent skill. Where `check-sdk.mjs` guards the + * *frontend* contract (a block's generated-hook prerequisites), this guards the + * *flow catalog* contract: that the committed `references/flows.json` in this + * skill is still a faithful, in-sync projection of the single source of truth in + * apps/blocks (`scripts/flows-content.mjs` -> resolved `src/flows/flows.json`), + * and that the harness copy hasn't drifted from the skill copy. + * + * The catalog is GENERATED, never hand-edited. The generator + * (apps/blocks/scripts/generate-flows.mjs) computes a `sotHash` over the + * resolved flows and stamps it into every emitted `flows.json`. This script + * recomputes that hash with the SAME canonicalization and asserts it matches — + * turning silent drift (someone edits a committed flows.json, or regenerates one + * copy but not the other) into a loud, actionable failure. + * + * Zero dependencies. Pure Node (>=18), node:crypto for sha256. Run from the + * skill repo root (or anywhere with --project / env overrides): + * + * node check-flows.mjs # verify this skill's catalog is in-sync + * node check-flows.mjs --project /path/repo # resolve the skill copy from a different root + * node check-flows.mjs --sot src/flows/flows.json # explicit SoT (relative to cwd) + * node check-flows.mjs --json # machine-readable report on stdout + * node check-flows.mjs --help + * + * SoT / harness / presets are located via (highest precedence first): + * --sot explicit SoT flows.json (resolved against cwd) — used by the + * in-repo `pnpm check:flows` in apps/blocks (`--sot src/flows/flows.json`). + * FLOWS_SOT env -> apps/blocks/src/flows/flows.json (resolved SoT artifact) + * findUp apps/blocks/src/flows/flows.json walked up from this script. + * FLOWS_HARNESS env -> agentic-flow .../references/flows.json (byte-twin of skill copy) + * FLOWS_PRESETS env -> constructive/packages/node-type-registry (preset resolution) + * If a path can't be resolved it is treated as "not reachable" and SKIPPED + * (not a failure) — except the skill copy and the SoT, which are required. + * + * Exit codes (mirroring check-sdk.mjs): + * 0 everything in sync (or the only-reachable checks all passed) + * 1 DRIFT — a hash mismatch, a byte mismatch, or a referential-integrity break + * 2 the check could not run (skill copy or SoT unreadable / unparseable / bad args) + * + * What it verifies: + * 1. SoT self-consistency: sha256(canonical({flows})) === embedded sotHash. + * 2. skill copy sotHash === SoT sotHash. + * 3. harness copy (if reachable) sotHash === SoT sotHash. + * 4. skill copy bytes === harness copy bytes (if reachable). + * 5. referential integrity per flow: status==='ga', blocks non-empty, preset + * resolves from node-type-registry (if reachable), modules ⊆ preset + * (compared on the display key — flows.json modules are NATIVE strings + + * ["name",{scope}] tuples; the preset side is normalized to match). + * + * On any drift it prints the remediation: "re-run: (cd apps/blocks && pnpm gen:flows)". + */ + +import crypto from 'node:crypto'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, resolve, dirname, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const requireCjs = createRequire(import.meta.url); + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +// The skill copy this script is bundled alongside: ../references/flows.json. +const SKILL_FLOWS = resolve(scriptDir, '..', 'references', 'flows.json'); + +// Remediation printed on every drift — regenerating from the SoT is the only fix. +const REMEDIATION = 're-run: (cd apps/blocks && pnpm gen:flows)'; + +// --------------------------------------------------------------------------- +// args +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const opts = { project: null, sot: null, json: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--project' || a === '-p') opts.project = resolve(argv[++i] ?? '.'); + else if (a === '--sot') opts.sot = resolve(argv[++i] ?? '.'); // explicit SoT flows.json, relative to cwd + else if (a === '--json') opts.json = true; + else if (a === '--help' || a === '-h') opts.help = true; + } + return opts; +} + +// --------------------------------------------------------------------------- +// reporting (mirrors check-sdk.mjs) +// --------------------------------------------------------------------------- +const C = process.stdout.isTTY + ? { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m` } + : { red: (s) => s, green: (s) => s, dim: (s) => s, bold: (s) => s, yellow: (s) => s }; + +function fail(code, msg) { + console.error(`${C.red('✗')} ${msg}`); + process.exit(code); +} + +const HELP = `check-flows.mjs — verify this skill's flow catalog is in sync with apps/blocks. + +Usage: + node check-flows.mjs [--project DIR] [--sot FILE] [--json] [--help] + + --project DIR root to resolve the skill copy of references/flows.json from + (default: relative to this script) + --sot FILE explicit SoT flows.json (relative to cwd). Highest precedence; + used by apps/blocks: \`check:flows --sot src/flows/flows.json\`. + --json emit a machine-readable report + --help show this help + +Env overrides (else auto-located via findUp): + FLOWS_SOT apps/blocks/src/flows/flows.json (the resolved source of truth) + FLOWS_HARNESS agentic-flow references/flows.json (byte-twin of the skill copy) + FLOWS_PRESETS constructive/packages/node-type-registry (for preset resolution) + +Exit codes: 0 in sync · 1 drift · 2 can't run. +Drift fix: ${REMEDIATION}`; + +// --------------------------------------------------------------------------- +// findUp — walk up from a start dir looking for a relative target. At each +// ancestor level it ALSO probes one level of siblings (`/*/`), +// which is what lets a sibling-worktree layout resolve: walking up from the +// skill worktree hits the shared parent (e.g. `.worktrees-v2/`), whose children +// include the dashboard worktree carrying `apps/blocks/src/flows/flows.json`. +// Direct ancestor matches always win over sibling matches; siblings are tried +// in sorted order for determinism. +// --------------------------------------------------------------------------- +function findUp(startDir, relTarget) { + let dir = startDir; + for (;;) { + const direct = join(dir, relTarget); + if (existsSync(direct)) return direct; + // One level of siblings under this ancestor. + let children; + try { + children = readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + } catch { + children = []; + } + for (const child of children) { + const sib = join(dir, child, relTarget); + if (existsSync(sib)) return sib; + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// --------------------------------------------------------------------------- +// CANONICALIZATION — replicated EXACTLY from +// apps/blocks/scripts/generate-flows.mjs. Do NOT "improve" it; the hash only +// matches if this is byte-for-byte the same algorithm: +// canonical(value): +// - arrays -> "[" + canonical(item) joined by "," + "]" (ORDER PRESERVED; +// module lists keep preset declaration order, flows keep +// authored order — do NOT sort arrays). +// - objects -> "{" + for each key in Object.keys(obj).sort(): +// JSON.stringify(key) + ":" + canonical(obj[key]) +// joined by "," + "}" (KEYS SORTED). +// - else -> JSON.stringify(value). +// No whitespace. sotHash = sha256_hex(canonical({ flows: resolvedFlows })). +// The envelope ({ generatedAt, source, sotHash, groups }) is NOT part of the hash. +// --------------------------------------------------------------------------- +function canonicalize(value) { + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + if (value && typeof value === 'object') { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function sotHashOf(flows) { + return crypto.createHash('sha256').update(canonicalize({ flows })).digest('hex'); +} + +// --------------------------------------------------------------------------- +// preset resolution — same dist-preferred / regex-source-fallback strategy as +// generate-flows.mjs, used ONLY for the referential-integrity check +// (modules ⊆ preset). If the registry isn't reachable, this check is skipped. +// --------------------------------------------------------------------------- +const NTR_REL = join('constructive', 'packages', 'node-type-registry'); + +function normalizeModule(entry) { + if (typeof entry === 'string') return entry; + if (Array.isArray(entry)) { + const [name, opts] = entry; + if (opts && typeof opts === 'object') { + if (typeof opts.scope === 'string') return `${name}:${opts.scope}`; + const keys = Object.keys(opts).sort(); + if (keys.length) return `${name}:${keys.map((k) => `${k}=${String(opts[k])}`).join(',')}`; + } + return name; + } + return String(entry); +} + +function resolvePresetFromDist(ntrRoot, presetName) { + const distIndex = join(ntrRoot, 'dist', 'module-presets', 'index.js'); + if (!existsSync(distIndex)) return null; + let mod; + try { + // Dist is CommonJS; load it through createRequire (zero-dep, no top-level await). + mod = requireCjs(distIndex); + } catch { + return null; + } + const getPreset = mod.getModulePreset ?? mod.default?.getModulePreset; + const preset = getPreset?.(presetName); + if (!preset || !Array.isArray(preset.modules)) return null; + return preset.modules.map(normalizeModule); +} + +function presetSourceFiles(ntrRoot) { + const dir = join(ntrRoot, 'src', 'module-presets'); + const byName = new Map(); + if (!existsSync(dir)) return byName; + let entries; + try { + entries = readdirSync(dir); + } catch { + return byName; + } + for (const file of entries) { + if (!file.endsWith('.ts') || file === 'index.ts' || file === 'types.ts') continue; + let text; + try { + text = readFileSync(join(dir, file), 'utf8'); + } catch { + continue; + } + const nameMatch = text.match(/name:\s*'([^']+)'/); + if (nameMatch) byName.set(nameMatch[1], text); + } + return byName; +} + +function parseModulesBlock(text) { + const start = text.indexOf('modules:'); + if (start === -1) return null; + const open = text.indexOf('[', start); + if (open === -1) return null; + let depth = 0; + let end = -1; + for (let i = open; i < text.length; i++) { + if (text[i] === '[') depth++; + else if (text[i] === ']') { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) return null; + const body = text.slice(open + 1, end); + const modules = []; + const tupleRe = /\[\s*'([^']+)'\s*,\s*\{([^}]*)\}\s*\]/g; + const consumed = []; + let m; + while ((m = tupleRe.exec(body)) !== null) { + const name = m[1]; + const optsBody = m[2]; + const scope = optsBody.match(/scope:\s*'([^']+)'/); + if (scope) modules.push(`${name}:${scope[1]}`); + else { + const kv = optsBody.match(/(\w+):\s*'?([^,'}]+)'?/); + modules.push(kv ? `${name}:${kv[1]}=${kv[2].trim()}` : name); + } + consumed.push([m.index, m.index + m[0].length]); + } + let plainSrc = body; + for (const [s, e] of consumed.reverse()) plainSrc = plainSrc.slice(0, s) + ' '.repeat(e - s) + plainSrc.slice(e); + const stringRe = /'([^']+)'/g; + while ((m = stringRe.exec(plainSrc)) !== null) modules.push(m[1]); + return modules; +} + +function resolvePresetFromSource(presetName, sourceMap, seen = new Set()) { + if (seen.has(presetName)) return []; + seen.add(presetName); + const text = sourceMap.get(presetName); + if (!text) return null; + const own = parseModulesBlock(text); + if (!own) return null; + const extendsMatch = text.match(/extends:\s*\[([^\]]*)\]/); + const parents = extendsMatch ? [...extendsMatch[1].matchAll(/'([^']+)'/g)].map((x) => x[1]) : []; + const merged = new Set(own); + for (const parent of parents) { + const parentMods = resolvePresetFromSource(parent, sourceMap, seen); + if (parentMods) for (const mod of parentMods) merged.add(mod); + } + return [...merged]; +} + +/** Returns { resolve(name)->string[]|null, via:'dist'|'regex-source'|null } or null if NTR unreachable. */ +function makePresetResolver(ntrRoot) { + if (!ntrRoot) return null; + const sourceMap = presetSourceFiles(ntrRoot); + let via = null; + const cache = new Map(); + function resolvePreset(name) { + if (cache.has(name)) return cache.get(name); + let mods = resolvePresetFromDist(ntrRoot, name); + if (mods && mods.length) { + via = via ?? 'dist'; + } else { + mods = resolvePresetFromSource(name, sourceMap); + if (mods && mods.length) via = 'regex-source'; + } + cache.set(name, mods && mods.length ? mods : null); + return cache.get(name); + } + return { resolvePreset, get via() { return via; } }; +} + +// --------------------------------------------------------------------------- +// payload loading +// --------------------------------------------------------------------------- +function loadPayload(file, label, { required }) { + if (!existsSync(file)) { + if (required) fail(2, `${label} not found at ${file}. Run from the skill repo root or pass --project / set the env override.`); + return null; + } + let bytes; + try { + bytes = readFileSync(file); + } catch (e) { + if (required) fail(2, `${label} unreadable (${file}): ${e.message}`); + return null; + } + let json; + try { + json = JSON.parse(bytes.toString('utf8')); + } catch (e) { + if (required) fail(2, `${label} is not valid JSON (${file}): ${e.message}`); + return null; + } + if (!Array.isArray(json.flows)) { + if (required) fail(2, `${label} has no \`flows\` array (${file}).`); + return null; + } + if (typeof json.sotHash !== 'string') { + if (required) fail(2, `${label} has no \`sotHash\` string (${file}).`); + return null; + } + return { file, bytes, json }; +} + +// --------------------------------------------------------------------------- +// locate SoT, harness, presets (env override -> findUp over candidate targets). +// relTargets may be a single string or an ordered list (first match wins) — the +// list covers worktree-name variants (e.g. `agentic-flow/` vs the actual +// `agentic-flow-blocks/` worktree dir) so the byte-twin harness copy resolves. +// --------------------------------------------------------------------------- +function locate(envVar, relTargets) { + const override = process.env[envVar]; + if (override) return isAbsolute(override) ? override : resolve(process.cwd(), override); + for (const rel of [].concat(relTargets)) { + const hit = findUp(scriptDir, rel); + if (hit) return hit; + } + return null; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(HELP); + process.exit(0); + } + + // Skill copy (required). --project overrides where we look for it. + const skillFlowsPath = opts.project ? join(opts.project, '.agents', 'skills', 'constructive-blocks', 'references', 'flows.json') : SKILL_FLOWS; + const skill = loadPayload(skillFlowsPath, 'skill flows.json', { required: true }); + + // SoT (required) — the resolved artifact the generator wrote. + // Precedence: --sot (cwd-relative, used by apps/blocks `pnpm check:flows`) > FLOWS_SOT env > findUp. + const sotPath = opts.sot ?? locate('FLOWS_SOT', join('apps', 'blocks', 'src', 'flows', 'flows.json')); + if (!sotPath) { + fail( + 2, + `Could not locate the SoT flows.json (apps/blocks/src/flows/flows.json) via findUp from ${scriptDir}.\n` + + ` Set FLOWS_SOT=/abs/path/to/apps/blocks/src/flows/flows.json (e.g. the dashboard worktree) and re-run.` + ); + } + const sot = loadPayload(sotPath, 'SoT flows.json', { required: true }); + + // Harness copy (optional — skipped if not reachable). Probe both the canonical + // `agentic-flow/` name and the active `agentic-flow-blocks/` worktree dir. + const harnessPath = locate('FLOWS_HARNESS', [ + join('agentic-flow', 'references', 'flows.json'), + join('agentic-flow-blocks', 'references', 'flows.json') + ]); + const harness = harnessPath ? loadPayload(harnessPath, 'harness flows.json', { required: false }) : null; + + // Preset resolver (optional — referential-integrity modules⊆preset skipped if unreachable). + const ntrRoot = locate('FLOWS_PRESETS', NTR_REL); + const presetResolver = makePresetResolver(ntrRoot); + + const checks = []; + let failed = false; + const add = (name, ok, detail) => { + checks.push({ name, ok, detail }); + if (!ok) failed = true; + }; + + // 1. SoT self-consistency. + const sotRecomputed = sotHashOf(sot.json.flows); + add( + 'sot-self-consistent', + sotRecomputed === sot.json.sotHash, + sotRecomputed === sot.json.sotHash + ? `sotHash ${sot.json.sotHash.slice(0, 12)}… matches recomputed` + : `embedded ${sot.json.sotHash} != recomputed ${sotRecomputed} (SoT flows.json was hand-edited)` + ); + + // 2. skill copy sotHash === SoT sotHash (recompute skill too, belt-and-suspenders). + const skillRecomputed = sotHashOf(skill.json.flows); + add( + 'skill-self-consistent', + skillRecomputed === skill.json.sotHash, + skillRecomputed === skill.json.sotHash ? 'skill embedded sotHash matches its own flows' : `skill embedded ${skill.json.sotHash} != recomputed ${skillRecomputed}` + ); + add( + 'skill-matches-sot', + skill.json.sotHash === sot.json.sotHash, + skill.json.sotHash === sot.json.sotHash ? 'skill sotHash === SoT sotHash' : `skill ${skill.json.sotHash} != SoT ${sot.json.sotHash}` + ); + + // 3 + 4. harness checks (only if reachable). + if (harness) { + add( + 'harness-matches-sot', + harness.json.sotHash === sot.json.sotHash, + harness.json.sotHash === sot.json.sotHash ? 'harness sotHash === SoT sotHash' : `harness ${harness.json.sotHash} != SoT ${sot.json.sotHash}` + ); + add( + 'skill-equals-harness-bytes', + skill.bytes.equals(harness.bytes), + skill.bytes.equals(harness.bytes) ? 'skill flows.json bytes === harness flows.json bytes' : 'skill and harness flows.json are NOT byte-identical (one copy was regenerated without the other)' + ); + } + + // 5. referential integrity (per flow). Hash is over the SoT, but integrity is + // asserted on the skill copy (the artifact this repo ships). They share a + // hash, so this is equivalent; we report against what's shipped here. + const flowIds = new Set(skill.json.flows.map((f) => f.id)); + const integrity = []; + for (const flow of skill.json.flows) { + const problems = []; + if (flow.status !== 'ga') problems.push(`status='${flow.status}' (only 'ga' allowed)`); + if (!Array.isArray(flow.blocks) || flow.blocks.length === 0) problems.push('blocks[] empty'); + const preset = flow.backend?.preset; + const modules = flow.backend?.modules; + if (!preset) problems.push('backend.preset missing'); + if (!Array.isArray(modules) || modules.length === 0) problems.push('backend.modules[] empty'); + for (const rel of flow.relatedFlows ?? []) { + if (!flowIds.has(rel)) problems.push(`relatedFlows -> unknown flow '${rel}'`); + } + // modules ⊆ preset (only when the registry is reachable AND the preset resolves). + // flows.json carries NATIVE module entries (plain strings + ["name",{scope}] + // tuples — provisioning-ready); the preset resolver normalizes its entries to + // display strings. Compare on the shared display key (normalizeModule) so a + // tuple `["memberships_module",{scope:"app"}]` matches the preset's + // `memberships_module:app`. + if (presetResolver && preset && Array.isArray(modules)) { + const presetMods = presetResolver.resolvePreset(preset); + if (presetMods === null) { + problems.push(`preset '${preset}' did not resolve from node-type-registry`); + } else { + const presetSet = new Set(presetMods.map(normalizeModule)); + const escapees = modules.map(normalizeModule).filter((m) => !presetSet.has(m)); + if (escapees.length) problems.push(`modules not ⊆ preset '${preset}': [${escapees.join(', ')}]`); + } + } + integrity.push({ id: flow.id, ok: problems.length === 0, problems }); + } + const integrityOk = integrity.every((i) => i.ok); + const presetNote = presetResolver ? `via ${presetResolver.via ?? 'unresolved'}` : 'preset resolution SKIPPED (node-type-registry not reachable)'; + add('referential-integrity', integrityOk, integrityOk ? `${integrity.length} flows OK (${presetNote})` : `${integrity.filter((i) => !i.ok).length}/${integrity.length} flows have problems (${presetNote})`); + + // ------------------------------------------------------------------------- + // report + // ------------------------------------------------------------------------- + if (opts.json) { + console.log( + JSON.stringify( + { + ok: !failed, + skill: skillFlowsPath, + sot: sotPath, + harness: harnessPath ?? null, + harnessReachable: !!harness, + presetsRoot: ntrRoot ?? null, + presetResolutionVia: presetResolver?.via ?? null, + sotHash: sot.json.sotHash, + checks, + integrity + }, + null, + 2 + ) + ); + process.exit(failed ? 1 : 0); + } + + console.log(C.bold('\nConstructive Blocks — flow catalog drift guard\n')); + console.log(`${C.dim('skill ')} ${skillFlowsPath}`); + console.log(`${C.dim('sot ')} ${sotPath}`); + console.log(`${C.dim('toolkit')} ${harnessPath ? harnessPath : C.yellow('(not reachable — skipped)')}`); + console.log(`${C.dim('presets')} ${ntrRoot ? `${ntrRoot} ${C.dim(`(${presetResolver?.via ?? 'unresolved'})`)}` : C.yellow('(not reachable — modules⊆preset skipped)')}`); + console.log(`${C.dim('sotHash')} ${sot.json.sotHash}\n`); + + for (const c of checks) { + console.log(`${c.ok ? C.green('✓') : C.red('✗')} ${c.name} ${C.dim(`— ${c.detail}`)}`); + } + + if (!integrityOk) { + console.log(''); + for (const i of integrity.filter((x) => !x.ok)) { + console.log(` ${C.red('✗')} ${C.bold(i.id)}: ${i.problems.join('; ')}`); + } + } + + if (failed) { + console.log(C.red('\n✗ Flow catalog drift detected.')); + console.log(`\n ${C.bold(REMEDIATION)}`); + console.log( + C.dim( + '\n The catalog is generated from apps/blocks/scripts/flows-content.mjs.\n' + + ' Never hand-edit references/flows.json or references/flow-catalog.md — regenerate.' + ) + ); + process.exit(1); + } + + console.log(C.green('\n✓ Flow catalog in sync.')); + process.exit(0); +} + +main(); diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs new file mode 100755 index 0000000..72b75a1 --- /dev/null +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.mjs @@ -0,0 +1,973 @@ +#!/usr/bin/env node +/** + * check-sdk.mjs — preflight check for installing Constructive data blocks. + * + * Part of the `constructive-blocks` agent skill. Implements the enforcement + * described in the SDK Binding Contract §9: before a data block is considered + * installable, its declared prerequisites (a co-located `.requires.json`, + * installed to `.constructive/blocks/`) MUST be satisfied by the host app's + * generated SDK. A block whose required op is absent fails here — with a precise + * message — instead of compiling against a guess. + * + * Zero dependencies. Pure Node (>=18). Run from the host app's project root: + * + * node check-sdk.mjs # check every installed manifest + * node check-sdk.mjs auth-sign-in-card # check one block (name or path) + * node check-sdk.mjs --project /path/ws # check a workspace root (or app pkg) + * node check-sdk.mjs --json # machine-readable report on stdout + * + * --project accepts EITHER the WORKSPACE ROOT (the dir holding packages/, the + * same the scaffold-provision/scaffold-frontend/wire-app scripts take) + * OR the app package dir directly. Given a workspace root, the actual app package + * (`packages/app`, else a root-level `app/`) is derived internally — its tsconfig + * + src/.constructive/blocks are what get checked. See resolveAppRoot(). + * + * Exit codes: + * 0 every prerequisite satisfied (or nothing to check) + * 1 a prerequisite is missing (alias / generated dir / op export) + * 2 the check could not run (no tsconfig, bad args, unreadable manifest) + * + * What it verifies (per contract §9): + * 1. the `@/generated/*` alias exists in the host tsconfig + * 2. the generated dir for each block's namespace exists (resolved via alias) + * 3. every mutation/query/model in requires.json is an export of that SDK. + * Models are matched SINGULAR-insensitively: the ORM accessor (and its + * `models/.ts` file) is always singular, so a manifest may declare a + * list model plural (`orgMemberships`) or singular (`orgMembership`) — both + * satisfy the on-disk `models/orgMembership.ts`. + * + * IMPORT-PRESENCE GATE: a missing op only HARD-FAILS when the block actually + * IMPORTS the hook that op maps to (from `@/generated/*` in the host source) + * — i.e. a genuine compile-against-a-missing-export. A manifest routinely + * declares the full capability surface (so the catalog is honest), but a + * block degrades when an op isn't deployed and simply never imports its hook + * (e.g. `org-members-list` declares removeOrgMember/transferOrgOwnership yet + * imports neither, referencing them only in comments/override seams). Such a + * declared-but-unimported op is reported as backend-pending, NEVER a failure. + * Op/model names listed in a manifest's optional `pending` array are likewise + * reported but never fail. (A wholly-missing generated dir/alias still fails + * independently — see §1–2.) + * 5. (advisory) `@constructive/blocks-runtime` appears mounted somewhere + * 6. (advisory, WARN-only) CONTRACT PREFLIGHT: when an installed block declares + * or imports a known arg-domain or defective op, emit a WARN naming the axis, + * the GAP-N, and the safe value — e.g. `createApiKey.accessLevel` only accepts + * {read_only, full_access} (a block shipping {read,write,admin} → live + * INVALID_ACCESS_LEVEL), or `sendVerificationEmail` aborts upstream (GAP-9). + * These NEVER change the exit code (the op exists + type-checks; only its + * runtime arg-domain/behavior is wrong) and are surfaced in --json as a + * `warnings[]` array. The table mirrors SKILL.md "Known SDK gaps". + * + * Drift detection (§9.4) and generating a missing SDK (§9.6) require `cnc + * codegen` + an endpoint + operator confirmation, so they are NOT run here — + * on failure this script prints the exact `cnc codegen` command to run. The + * skill's SKILL.md drives that remediation. + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join, resolve, dirname, basename, isAbsolute } from 'node:path'; + +const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage']); +const SRC_EXT = /\.(?:[cm]?tsx?|d\.ts)$/; + +// --------------------------------------------------------------------------- +// args +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const opts = { project: process.cwd(), only: null, json: false, manifestsDir: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--project' || a === '-p') opts.project = resolve(argv[++i] ?? '.'); + else if (a === '--manifests-dir' || a === '-m') opts.manifestsDir = argv[++i] ?? null; + else if (a === '--json') opts.json = true; + else if (a === '--help' || a === '-h') opts.help = true; + else if (!a.startsWith('-')) opts.only = a; // block name or manifest path + } + return opts; +} + +// --------------------------------------------------------------------------- +// app-package locator — accept a WORKSPACE ROOT and derive the app PACKAGE. +// +// The harness scaffolders (scaffold-provision/scaffold-frontend/wire-app) all +// take the WORKSPACE ROOT — the dir holding `packages/` — as their +// argument; the pgpm nextjs template unpacks the Next.js app one level down at +// `/packages/app` (an older layout uses a root-level `/app`). But +// this check consumes the app PACKAGE directly: it reads that package's +// `tsconfig.json` for the `@/generated/*` alias and scans its +// `src/.constructive/blocks` for manifests. So when `--project` is pointed at a +// workspace root (the natural thing, matching the scaffolders) the manifests & +// tsconfig sit under `packages/app`, NOT at the root — the symptom the 5-app run +// hit: `check-sdk --project ` → "No data-block manifests". +// +// resolveAppRoot() reconciles the two: given the supplied `--project`, it returns +// the app package dir it denotes. Resolution mirrors wire-app.mjs's appUnder(): +// 1. the project IS already the app package (holds tsconfig.json + src/) — used +// as-is, so an explicit package dir (or a flat/non-nested layout, incl. the +// test fixtures, whose root carries tsconfig.json + src/) keeps working +// unchanged. This is the back-compat path. +// 2. else probe `/packages/app` then `/app` for that same +// marker and derive the package — the workspace-root path. +// 3. else fall back to the project as-given ONLY when it at least carries a +// tsconfig.json (a degenerate layout we shouldn't second-guess); otherwise +// FAIL LOUDLY (exit 2) naming the root + the dirs probed, rather than +// silently proceeding against a root with no tsconfig/manifests (which would +// surface as the misleading "No data-block manifests" / "No tsconfig"). +// +// The marker is `tsconfig.json` + `src/` (not package.json + src/ as wire-app +// uses): tsconfig is what THIS check actually consumes, and a workspace root +// carries package.json + tsconfig.json but crucially NO `src/`, so requiring +// `src/` is exactly what distinguishes the app package from the workspace root. +// `packages/app` is probed before `app` (template default first), matching the +// scaffolders' own preference order. +// --------------------------------------------------------------------------- +function isAppPackage(dir) { + // The app package always carries a tsconfig.json (the alias source) AND a src/ + // tree (where codegen + the .constructive/blocks manifests live). A workspace + // root has tsconfig.json + package.json but no src/, so it is NOT matched here. + return existsSync(join(dir, 'tsconfig.json')) && existsSync(join(dir, 'src')); +} + +function resolveAppRoot(project) { + // 1) back-compat: the supplied dir IS already the app package (or a flat + // fixture layout) — use it verbatim, no derivation. + if (isAppPackage(project)) return { dir: project, derivedFrom: null }; + // 2) workspace root: derive the nested app package the template unpacks. Probe + // packages/app first (template default), then a root-level app/. + for (const sub of ['packages/app', 'app']) { + const cand = join(project, sub); + if (isAppPackage(cand)) return { dir: cand, derivedFrom: project }; + } + // 3) neither the root nor packages/app|app is an app package. If the root at + // least has a tsconfig.json, defer to loadTsconfig() (a degenerate layout we + // won't override — preserves the original "no manifests"/tsconfig messages). + // Otherwise FAIL LOUDLY: a bare workspace root with no resolvable app. + if (existsSync(join(project, 'tsconfig.json'))) return { dir: project, derivedFrom: null }; + const probed = ['packages/app', 'app'].map((s) => join(project, s)).join(', '); + fail( + 2, + `No app package found at or under ${project}.\n` + + ` Looked for tsconfig.json + src/ at the project itself and under: ${probed}.\n` + + ` Pass the WORKSPACE ROOT (the dir holding packages/, the same the scaffolders take) ` + + `or the app package dir directly (the dir with tsconfig.json + src/).` + ); +} + +// --------------------------------------------------------------------------- +// tsconfig: read compilerOptions.paths (+ baseUrl), following one `extends`. +// JSONC-tolerant (tsconfig allows comments + trailing commas). +// +// Comment stripping is STRING-AWARE: a single-pass scanner that ignores `//` +// and `/* */` sequences occurring inside quoted strings. A naive regex would +// corrupt valid JSON like the path glob `"@/*": ["./src/*/index"]`, whose +// `/*` … `*/` substrings (spread across string literals) look like a block +// comment and get devoured. Escapes (`\"`, `\\`) inside strings are honoured. +// --------------------------------------------------------------------------- +function stripJsonComments(txt) { + let out = ''; + let i = 0; + const n = txt.length; + let inStr = false; // inside a double-quoted string literal + while (i < n) { + const c = txt[i]; + const next = i + 1 < n ? txt[i + 1] : ''; + if (inStr) { + out += c; + if (c === '\\') { + // copy the escaped char verbatim (handles \" and \\) + if (i + 1 < n) out += txt[i + 1]; + i += 2; + continue; + } + if (c === '"') inStr = false; + i += 1; + continue; + } + if (c === '"') { + inStr = true; + out += c; + i += 1; + continue; + } + if (c === '/' && next === '/') { + // line comment: skip to (but keep) the newline + i += 2; + while (i < n && txt[i] !== '\n' && txt[i] !== '\r') i += 1; + continue; + } + if (c === '/' && next === '*') { + // block comment: skip through the closing */ + i += 2; + while (i < n && !(txt[i] === '*' && i + 1 < n && txt[i + 1] === '/')) i += 1; + i += 2; // consume the closing */ + continue; + } + out += c; + i += 1; + } + return out; +} + +// Strip trailing commas (`,]` / `,}`) that sit OUTSIDE string literals, so a +// comma inside a string value is never touched. Runs after comment stripping. +function stripTrailingCommas(txt) { + let out = ''; + let i = 0; + const n = txt.length; + let inStr = false; + while (i < n) { + const c = txt[i]; + if (inStr) { + out += c; + if (c === '\\') { + if (i + 1 < n) out += txt[i + 1]; + i += 2; + continue; + } + if (c === '"') inStr = false; + i += 1; + continue; + } + if (c === '"') { + inStr = true; + out += c; + i += 1; + continue; + } + if (c === ',') { + // look ahead past whitespace for a closing } or ] + let j = i + 1; + while (j < n && /\s/.test(txt[j])) j += 1; + if (j < n && (txt[j] === '}' || txt[j] === ']')) { + i += 1; // drop the comma + continue; + } + } + out += c; + i += 1; + } + return out; +} + +function readJsonc(file) { + let txt = readFileSync(file, 'utf-8'); + txt = stripJsonComments(txt); // string-aware: comments only outside strings + txt = stripTrailingCommas(txt); // string-aware trailing-comma removal + return JSON.parse(txt); +} + +function loadTsconfig(projectRoot) { + const path = join(projectRoot, 'tsconfig.json'); + if (!existsSync(path)) return null; + let cfg; + try { + cfg = readJsonc(path); + } catch (e) { + fail(2, `Could not parse ${path}: ${e.message}`); + } + let co = cfg.compilerOptions ?? {}; + let baseDir = projectRoot; + // One level of `extends`: pull paths/baseUrl from the base if absent here. + if (cfg.extends && (!co.paths || co.baseUrl === undefined)) { + try { + const extPath = isAbsolute(cfg.extends) ? cfg.extends : resolve(projectRoot, cfg.extends); + const resolved = existsSync(extPath) ? extPath : `${extPath}.json`; + if (existsSync(resolved)) { + const base = readJsonc(resolved); + const baseCo = base.compilerOptions ?? {}; + co = { ...baseCo, ...co, paths: co.paths ?? baseCo.paths }; + if (co.baseUrl === undefined && baseCo.baseUrl !== undefined) { + baseDir = dirname(resolved); + co.baseUrl = baseCo.baseUrl; + } + } + } catch { + /* best-effort */ + } + } + const baseUrl = co.baseUrl ? resolve(baseDir, co.baseUrl) : projectRoot; + return { paths: co.paths ?? {}, baseUrl }; +} + +// Resolve the on-disk dir an alias key maps to (first target), substituting `*`. +function resolveAlias(target, substitution, baseUrl) { + const filled = target.replace(/\*/g, substitution).replace(/\/$/, ''); + return resolve(baseUrl, filled); +} + +// Find the generated dir for a namespace via `@/generated/*`, `@/generated/`, +// or `@/generated//*`. Returns { dir, aliasKey } or null. +function resolveGeneratedDir(ns, paths, baseUrl) { + const candidates = [`@/generated/${ns}`, `@/generated/${ns}/*`, `@/generated/*`, `@/generated/*/`]; + for (const key of candidates) { + const targets = paths[key]; + if (!targets || !targets.length) continue; + const sub = key === `@/generated/*` || key === `@/generated/*/` ? ns : ''; + const dir = resolveAlias(targets[0], sub, baseUrl); + return { dir, aliasKey: key }; + } + return null; +} + +function hasGeneratedAlias(paths) { + return Object.keys(paths).some((k) => k.startsWith('@/generated/')); +} + +// --------------------------------------------------------------------------- +// SDK introspection: collect exported identifiers + model file names. +// We scan every source file (so leaf `export function useXMutation` is found +// regardless of how the barrels re-export) and parse two export forms: +// export (async)? (function|const|let|var|class|type|interface|enum) NAME +// export (type)? { A, B as C, type D } ← captures the EXPORTED name +// --------------------------------------------------------------------------- +function walk(dir, files = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return files; + } + for (const e of entries) { + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) walk(join(dir, e.name), files); + } else if (SRC_EXT.test(e.name)) { + files.push(join(dir, e.name)); + } + } + return files; +} + +const DECL_RE = /export\s+(?:async\s+)?(?:function|const|let|var|class|type|interface|enum)\s+([A-Za-z0-9_$]+)/g; +const LIST_RE = /export\s+(?:type\s+)?\{([^}]*)\}/g; + +function collectSdk(sdkDir) { + const exports = new Set(); + const models = new Set(); + for (const file of walk(sdkDir)) { + let txt; + try { + txt = readFileSync(file, 'utf-8'); + } catch { + continue; + } + let m; + while ((m = DECL_RE.exec(txt))) exports.add(m[1]); + while ((m = LIST_RE.exec(txt))) { + for (let item of m[1].split(',')) { + item = item.trim().replace(/^type\s+/, ''); + if (!item) continue; + const as = item.split(/\s+as\s+/); + const name = (as[1] ?? as[0]).trim(); + if (/^[A-Za-z0-9_$]+$/.test(name)) exports.add(name); + } + } + // model accessor signal: a file living under a `models/` directory. + if (/(?:^|\/)models\//.test(file.replace(/\\/g, '/'))) { + models.add(basename(file).replace(SRC_EXT, '')); + } + } + // Singular comparison keys for every model file basename. The ORM exposes a + // SINGULAR accessor (`db.orgMembership`, file `models/orgMembership.ts`) even + // for list queries, so a manifest that declares the model in the plural + // (`orgMemberships`) must still match. Normalising BOTH the on-disk name and + // the declared name through the same singulariser collapses plural-manifest, + // singular-manifest, and singular-file onto one key — see §model check. + const modelKeys = new Set([...models].map(singularizeModel)); + return { exports, models, modelKeys }; +} + +// op name (camelCase GraphQL op) → expected generated hook identifier. +const pascal = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); +const mutationHook = (op) => `use${pascal(op)}Mutation`; +const queryHook = (op) => `use${pascal(op)}Query`; + +// Singularise a camelCase model accessor for comparison. The ORM accessor (and +// its `models/.ts` file) is ALWAYS singular, so a manifest may legally +// declare the model singular (`orgMembership`, `email`) or — as some catalog +// manifests do — plural (`orgMemberships`, `users`). Normalising every name +// through this one function makes both forms compare equal to the on-disk +// singular file (the "make both-correct" rule of the SDK Binding Contract). +// +// Only the trailing word is inflected (operates on the final char-run, so +// `orgMemberships` → `orgMembership`, not the leading `org`). Conservative: +// nouns that are already singular but end in a sibilant cluster are uncommon +// among generated accessors, and an over- or under-singularised key simply +// falls back to the exact-name check the caller also performs. +function singularizeModel(name) { + if (typeof name !== 'string' || name.length < 2) return name; + if (/[^aeiou]ies$/i.test(name)) return name.slice(0, -3) + 'y'; // identities → identity + if (/(?:ses|xes|zes|ches|shes)$/i.test(name)) return name.slice(0, -2); // boxes → box + if (/[^s]s$/i.test(name)) return name.slice(0, -1); // users → user, orgMemberships → orgMembership + return name; // address, status, email, phoneNumber — leave untouched +} + +// --------------------------------------------------------------------------- +// manifests: read .constructive/blocks/*.requires.json (or a named one). +// A manifest is either a single { namespace, mutations, queries, models } +// object, or { requires: [ {…}, … ] } for cross-namespace blocks. +// --------------------------------------------------------------------------- +// Candidate manifest dirs, in priority order. shadcn writes block manifests to +// `/src/.constructive/blocks` whenever the blocks registry target sits +// under src/ (the common Next.js layout) — the project-root `.constructive` is +// only used when the target is at the root. We scan BOTH so manifests are never +// silently missed (which would false-pass the check). An explicit +// --manifests-dir override short-circuits discovery. +function manifestDirs(projectRoot, override) { + if (override) { + const dir = isAbsolute(override) ? override : resolve(projectRoot, override); + return [dir]; + } + return [join(projectRoot, '.constructive', 'blocks'), join(projectRoot, 'src', '.constructive', 'blocks')]; +} + +// Primary dir — used in messages (the location the operator should expect). +function manifestDir(projectRoot, override) { + return manifestDirs(projectRoot, override)[0]; +} + +function findManifests(projectRoot, only, override) { + const dirs = manifestDirs(projectRoot, override); + if (only) { + // explicit path, or a block name resolved under any candidate dir + const direct = isAbsolute(only) ? only : resolve(projectRoot, only); + if (existsSync(direct) && statSync(direct).isFile()) return [direct]; + const fileName = only.endsWith('.requires.json') ? only : `${only}.requires.json`; + const tried = []; + for (const dir of dirs) { + const named = join(dir, fileName); + tried.push(named); + if (existsSync(named)) return [named]; + } + fail(2, `No manifest found for "${only}" (looked for ${tried.join(', ')}).`); + } + // De-dupe by manifest file name. Dirs are scanned in priority order (root + // before src/), so the first occurrence of a given `.requires.json` + // wins — covering both two candidate dirs that resolve to the same place AND + // the same block accidentally present in both locations (otherwise it would + // be reported twice). Distinct blocks keep distinct file names, so this never + // merges different manifests. + const seen = new Set(); + const found = []; + for (const dir of dirs) { + if (!existsSync(dir)) continue; + for (const f of readdirSync(dir)) { + if (!f.endsWith('.requires.json')) continue; + if (seen.has(f)) continue; + seen.add(f); + found.push(join(dir, f)); + } + } + return found.sort(); +} + +function normalizeRequirements(raw) { + const list = Array.isArray(raw?.requires) ? raw.requires : [raw]; + return list.map((r) => ({ + namespace: r.namespace, + mutations: r.mutations ?? [], + queries: r.queries ?? [], + models: r.models ?? [], + // Optional: op/model names the block declares as backend-PENDING — a seam + // it ships for a procedure not yet deployed in any public schema (e.g. + // `transferOrgOwnership`, `removeOrgMember`). These are reported but DO NOT + // fail the check: a correctly-wired block that merely carries a pending + // seam must not exit 1. A missing op that is NOT declared pending still + // fails clearly. Accepts a flat array or a per-kind { mutations, queries }. + pending: new Set([...(Array.isArray(r.pending) ? r.pending : []), ...(r.pending?.mutations ?? []), ...(r.pending?.queries ?? []), ...(r.pending?.models ?? [])]) + })); +} + +// --------------------------------------------------------------------------- +// CONTRACT PREFLIGHT — known arg-domain + defective/RLS-blocked op advisories. +// +// A data-driven, WARN-only layer (NEVER a hard-fail) over the confirmed-live +// platform facts in the harness's PLATFORM-GAPS.md + planning/upstream-gaps- +// stress-test-2026-06-05.md. The import-presence binding gate above answers "does +// the op EXIST in the SDK?"; this layer answers a different question the SDK can't +// see: "this op exists and type-checks, but calling it the way a block ships it +// fails at RUNTIME (wrong arg-domain) or no-ops (a known upstream defect)." +// +// Why WARN and not a new hard-fail class: every op below belongs to a **GA block** +// whose SDK export is genuinely present — failing the check would false-fail blocks +// that ship today and pass the binding gate. The harness reads `warnings[]` from +// --json to surface the safe value / known defect at build time; a human run prints +// them under a "contract advisories" heading. Exit code is unchanged by warnings. +// +// The table mirrors SKILL.md "Known SDK gaps" (the prose table is the human-facing +// source; this is its executable twin). Keep them in sync: a new GAP-N row in +// SKILL.md that has an op signature should gain an entry here. +// +// Each axis: +// kind 'arg-domain' (a field/enum has a constrained safe set the block +// violates) | 'defective' (the op exists but no-ops / RLS-denies / +// aborts at runtime). +// ops GraphQL op name(s) (camelCase, pre-hook) this axis attaches to. +// A manifest matches when it DECLARES the op (mutations/queries) OR +// the host source IMPORTS the op's generated hook. +// gap the PLATFORM-GAPS GAP-N id (the escalation channel). +// safe for arg-domain: the values that actually work at runtime. +// bad for arg-domain: the values a block is known to ship that fail. +// field for arg-domain: the argument/enum the domain constrains. +// note one-line operator-facing summary (symptom + safe action). +// sources literal substrings searched in the host source to corroborate an +// arg-domain WARN (e.g. the bad enum values a block hard-codes). A +// source hit RAISES confidence ('confirmed') vs a name-only match +// ('declared'); never required to emit the WARN. +// --------------------------------------------------------------------------- +const KNOWN_AXES = [ + { + id: 'createApiKey-accessLevel', + kind: 'arg-domain', + ops: ['createApiKey'], + gap: 'GAP (auth-api-key-create-dialog)', + field: 'accessLevel', + safe: ['read_only', 'full_access'], + bad: ['read', 'write', 'admin'], + sources: ['read_only', 'full_access', "'read'", "'write'", "'admin'", '"read"', '"write"', '"admin"', 'accessLevelOptions'], + note: "createApiKey.accessLevel only accepts {read_only, full_access}; the auth-api-key-create-dialog ships {read,write,admin} → live INVALID_ACCESS_LEVEL. Pass read_only or full_access. (createApiKey also enforces STEP_UP_REQUIRED server-side.)" + }, + { + id: 'createUser-org-rls', + kind: 'defective', + ops: ['createUser', 'createOrganization'], + gap: 'GAP-6', + note: "createUser(type=2 Organization)/createOrganization is RLS-denied for an authenticated session (`new row violates row-level security policy for table \"users\"`) — no self-service org can be minted on the b2b tier. Confirmed live via both the block and the direct API. No app-side workaround; upstream (constructive-db)." + }, + { + id: 'sessions-list', + kind: 'defective', + ops: ['userSessions', 'sessions'], + gap: 'GAP-2', + note: "No userSessions list query is exposed (user_sessions is private, no Connection) — the Sessions flow cannot enumerate sessions to revoke. auth-account-sessions-list is out of frontend scope until an API exposes a sessions Connection." + }, + { + id: 'revokeSession-id', + kind: 'defective', + ops: ['revokeSession'], + gap: 'GAP-2', + note: "revokeSession(id) returns SESSION_NOT_FOUND for the id on a signIn/signUp result (auth-result id is a UUIDv5 identity id, not the sessions-row UUIDv7; revokeSession also reads user_sessions while signIn writes sessions). Treat sessions-revoke as backend-pending; do NOT hand-craft a session id." + }, + { + id: 'revokeApiKey-noop', + kind: 'defective', + ops: ['revokeApiKey'], + gap: 'GAP-3', + note: "revokeApiKey returns true and writes an audit-log entry but never sets revoked_at — the key keeps working. Do NOT treat its `true` as a successful revoke (security footgun). Upstream defect." + }, + { + id: 'sendVerificationEmail-abort', + kind: 'defective', + ops: ['sendVerificationEmail'], + gap: 'GAP-9', + note: "sendVerificationEmail aborts before any email enqueues (`user_secrets_del(uuid, text[]) does not exist` — signature/overload mismatch). Email-verification is unreachable on auth:email; the send raises server-side. No workaround (upstream constructive-db)." + }, + { + id: 'sendAccountDeletionEmail-noop', + kind: 'defective', + ops: ['sendAccountDeletionEmail'], + gap: 'GAP-10', + note: "sendAccountDeletionEmail returns HTTP 200 but enqueues nothing (silent no-op) — the UI claims 'a confirmation email has been sent' while Mailpit stays empty, so deletion can never be confirmed. Do NOT hand-roll the deletion email. Upstream (constructive-db)." + }, + { + id: 'forgotPassword-empty-selection', + kind: 'defective', + ops: ['forgotPassword', 'signOut'], + gap: 'GAP-11', + note: "forgot-password-card + sign-out-button (dashboard-blocks) ship an empty GraphQL selection (selection:{fields:{}}) that codegen rejects (`forgotPassword must have a selection of subfields`) — the block cannot issue its mutation. App-local fix: set the selection to { clientMutationId: true }. (signOut codegen is also broken per GAP-4.) Upstream owner is dashboard-blocks." + } + // NOTE — GAP-5 org-admin seams (`removeOrgMember` / `transferOrgOwnership` / + // `deleteOrg`) are deliberately NOT in this table. Those ops are *absent* + // (not-yet-deployed), which the BINDING gate's existing `pending`/import-presence + // mechanism already surfaces (declared-but-unimported → informational ◦, or a + // manifest `pending` entry). This contract layer covers the orthogonal class the + // binding gate cannot see: ops that EXIST + type-check but fail/no-op/abort at + // runtime (arg-domain, RLS-deny, silent no-op). Adding GAP-5 here would duplicate + // the binding gate and is intentionally left to it. +]; + +// Build an op → axis index once (an op may map to at most one axis here). +const AXIS_BY_OP = new Map(); +for (const axis of KNOWN_AXES) for (const op of axis.ops) if (!AXIS_BY_OP.has(op)) AXIS_BY_OP.set(op, axis); + +// Does the host source literally contain any of the corroborating substrings? +// Used only to upgrade an arg-domain WARN from 'declared' to 'confirmed' (the +// block hard-codes a bad enum value). Scans the same src/ tree as the import +// collector; best-effort and never required to emit a WARN. +function sourceContainsAny(projectRoot, needles) { + if (!needles || !needles.length) return false; + const src = join(projectRoot, 'src'); + const root = existsSync(src) ? src : projectRoot; + for (const file of walk(root)) { + let txt; + try { + txt = readFileSync(file, 'utf-8'); + } catch { + continue; + } + for (const n of needles) if (txt.includes(n)) return true; + } + return false; +} + +// Walk the manifests' declared ops + the host's imported generated symbols and +// collect a WARN for every known axis they touch. Returns a flat warnings[]: +// { id, kind, gap, op, block, namespace, field?, safe?, bad?, via, confidence, message } +// `via` is 'declared' (named in a requires.json) or 'imported' (its hook is +// imported from @/generated/*); `confidence` is 'confirmed' when corroborating +// source text was found, else 'declared'/'imported'. WARNs NEVER affect exit code. +function collectContractWarnings(report, importedSymbols, projectRoot) { + const warnings = []; + const seen = new Set(); // de-dupe (axis,block,namespace,op,via) + + // helper: which import names corroborate this op? the op's mutation OR query hook. + const opImported = (op) => importedSymbols.has(mutationHook(op)) || importedSymbols.has(queryHook(op)); + + const push = (axis, op, block, namespace, via) => { + const key = `${axis.id}|${block}|${namespace}|${op}|${via}`; + if (seen.has(key)) return; + seen.add(key); + // Corroborate an arg-domain WARN by looking for the bad enum literals the + // block hard-codes (quoted both ways). A hit upgrades 'declared'/'imported' + // to 'confirmed'; otherwise confidence is just the discovery channel. + let confidence = via; + if (axis.kind === 'arg-domain' && Array.isArray(axis.bad)) { + const needles = axis.bad.flatMap((v) => [`'${v}'`, `"${v}"`]); + if (sourceContainsAny(projectRoot, needles)) confidence = 'confirmed'; + } + const head = + axis.kind === 'arg-domain' + ? `arg-domain ${op}.${axis.field} — safe ${JSON.stringify(axis.safe)}, NOT ${JSON.stringify(axis.bad)}` + : `defective op ${op}`; + warnings.push({ + id: axis.id, + kind: axis.kind, + gap: axis.gap, + op, + block, + namespace, + field: axis.field ?? null, + safe: axis.safe ?? null, + bad: axis.bad ?? null, + via, + confidence, + message: `[${axis.gap}] ${head}. ${axis.note}` + }); + }; + + for (const b of report) { + for (const ns of b.namespaces) { + for (const o of ns.ops) { + const axis = AXIS_BY_OP.get(o.op); + if (!axis) continue; + // 'declared' — the op is named in this block's manifest (always true here, + // since ns.ops comes from the manifest). 'imported' takes precedence as the + // stronger signal (the block actually wires the hook). + const via = o.kind !== 'model' && opImported(o.op) ? 'imported' : 'declared'; + push(axis, o.op, b.block, ns.namespace, via); + } + } + } + // Also flag axes whose hook the host IMPORTS but which no manifest declared + // (e.g. a block author calls createApiKey directly without a requires.json entry, + // or a presentational wrapper imports the hook). Attributed to '(imported)'. + for (const [op, axis] of AXIS_BY_OP) { + if (opImported(op)) { + const already = warnings.some((w) => w.id === axis.id && w.op === op); + if (!already) push(axis, op, '(imported)', '(host source)', 'imported'); + } + } + return warnings; +} + +// --------------------------------------------------------------------------- +// advisory: is mounted anywhere in the host source? +// --------------------------------------------------------------------------- +function runtimeMounted(projectRoot) { + const src = join(projectRoot, 'src'); + const root = existsSync(src) ? src : projectRoot; + for (const file of walk(root)) { + try { + if (/]/.test(readFileSync(file, 'utf-8'))) return true; + } catch { + /* ignore */ + } + } + return false; +} + +// --------------------------------------------------------------------------- +// imported generated symbols: which `@/generated/*` identifiers does the host +// source ACTUALLY import? (§9 import-presence gate.) +// +// The gate hard-fails only on ops a block genuinely IMPORTS — not ops merely +// DECLARED in its requires.json. A correctly-wired block routinely declares the +// full capability surface in its manifest yet degrades when an op isn't +// deployed: `org-members-list` declares removeOrgMember + transferOrgOwnership +// but imports only useUpdateOrgMembershipMutation / useDeleteOrgMembershipMutation, +// referencing the absent procs solely in comments/override seams. Such a block +// compiles and runs; failing it would be a false negative. +// +// So we scan for the bindings actually pulled from a `@/generated/...` module +// and key the hard-fail on import-presence. Detection is STATEMENT-AWARE: only +// the named/default/namespace bindings of a real `import … from '@/generated/…'` +// are collected. A symbol that appears only in a comment or doc block (e.g. +// "useTransferOrgOwnershipMutation does NOT exist yet") is NOT an import and is +// never counted — otherwise the comment alone would re-introduce the false fail. +// +// We collect into one project-wide set (the SOURCE name, post-`as`-rename, so an +// `import { useFooMutation as foo }` still registers `useFooMutation`). Keying by +// the generated name rather than per-file keeps it robust to barrel re-exports +// and is sufficient: the check asks "is the hook this op maps to imported from +// the SDK anywhere?", which is exactly the compile-against-a-missing-export risk. +const GEN_IMPORT_RE = /import\s+([^;'"]*?)\s+from\s+['"]@\/generated\/[^'"]*['"]/g; + +function collectGeneratedImports(projectRoot) { + const src = join(projectRoot, 'src'); + const root = existsSync(src) ? src : projectRoot; + const imported = new Set(); + for (const file of walk(root)) { + let txt; + try { + txt = readFileSync(file, 'utf-8'); + } catch { + continue; + } + let m; + GEN_IMPORT_RE.lastIndex = 0; + while ((m = GEN_IMPORT_RE.exec(txt))) { + // clause = whatever sits between `import` and `from '@/generated/…'`: + // { a, b as c, type D } | Foo | * as NS | Foo, { a } + let clause = m[1].trim(); + const brace = clause.match(/\{([^}]*)\}/); + if (brace) { + for (let item of brace[1].split(',')) { + item = item.trim().replace(/^type\s+/, ''); + if (!item) continue; + const as = item.split(/\s+as\s+/); // SOURCE name = before `as` + const name = (as[0] ?? '').trim(); + if (/^[A-Za-z0-9_$]+$/.test(name)) imported.add(name); + } + clause = clause.replace(/\{[^}]*\}/, '').replace(/^\s*,|,\s*$/g, '').trim(); + } + // default / namespace binding remnant (e.g. `Foo` or `* as NS`) + const def = clause.replace(/^\*\s+as\s+/, '').trim(); + if (/^[A-Za-z0-9_$]+$/.test(def)) imported.add(def); + } + } + return imported; +} + +// --------------------------------------------------------------------------- +// reporting +// --------------------------------------------------------------------------- +const C = process.stdout.isTTY + ? { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` } + : { red: (s) => s, green: (s) => s, dim: (s) => s, bold: (s) => s }; + +function fail(code, msg) { + console.error(`${C.red('✗')} ${msg}`); + process.exit(code); +} + +const HELP = `check-sdk.mjs — verify the host SDK satisfies installed Constructive data blocks. + +Usage: + node check-sdk.mjs [block] [--project DIR] [--manifests-dir DIR] [--json] + + [block] a block name (auth-sign-in-card) or manifest path; omit to check all + --project DIR workspace root OR app package dir to check (default: cwd). + A workspace root (holding packages/, as the scaffolders take) + resolves to its app package (packages/app | app) internally. + --manifests-dir DIR explicit .constructive/blocks dir (overrides auto-discovery) + --json emit a machine-readable report (includes a warnings[] array) + --help show this help + +In addition to the hard binding gate, the check emits WARN-only CONTRACT +ADVISORIES for known arg-domain / defective ops an installed block touches +(e.g. createApiKey.accessLevel ∈ {read_only, full_access}; sendVerificationEmail +aborts upstream). Advisories never change the exit code; read them from +warnings[] in --json. The advisory table mirrors SKILL.md "Known SDK gaps". + +Manifests are auto-discovered under both /.constructive/blocks and +/src/.constructive/blocks (shadcn writes to the latter when the blocks +target lives under src/). Use --manifests-dir to point at a non-standard location.`; + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(HELP); + process.exit(0); + } + + // Accept a WORKSPACE ROOT for --project (the same the scaffolders take) + // and derive the app PACKAGE (packages/app | app) it denotes; an explicit app + // package dir is used as-is (back-compat). Everything below (tsconfig, manifest + // discovery, source scans) then operates on the resolved package. Fails loudly + // when no app package resolves under the given root (see resolveAppRoot). + const resolved = resolveAppRoot(opts.project); + if (resolved.derivedFrom) { + console.error(`${C.dim('•')} resolved app package ${resolved.dir} (from workspace root ${resolved.derivedFrom})`); + } + opts.project = resolved.dir; + + const ts = loadTsconfig(opts.project); + if (!ts) fail(2, `No tsconfig.json in ${opts.project}. Run from the host app root or pass --project.`); + + const manifests = findManifests(opts.project, opts.only, opts.manifestsDir); + if (!manifests.length) { + const where = opts.manifestsDir ? manifestDir(opts.project, opts.manifestsDir) : manifestDirs(opts.project).join(' or '); + console.log(`${C.dim('•')} No data-block manifests in ${where} — nothing to check.`); + process.exit(0); + } + + const aliasOk = hasGeneratedAlias(ts.paths); + // Which `@/generated/*` identifiers does the host source actually import? The + // hard-fail is gated on this set (import-presence, §9): an op a block declares + // but does not import is backend-pending, not a failure (it degrades). + const importedSymbols = collectGeneratedImports(opts.project); + const sdkCache = new Map(); // ns -> { dir, sdk } | { dir:null } + const report = []; + let failed = false; + + for (const file of manifests) { + let raw; + try { + raw = JSON.parse(readFileSync(file, 'utf-8')); + } catch (e) { + fail(2, `Could not parse manifest ${file}: ${e.message}`); + } + const block = basename(file).replace(/\.requires\.json$/, ''); + const reqs = normalizeRequirements(raw); + const blockEntry = { block, namespaces: [] }; + + for (const req of reqs) { + const ns = req.namespace; + const nsEntry = { namespace: ns, aliasOk, generatedDir: null, ops: [] }; + + if (!sdkCache.has(ns)) { + const loc = aliasOk ? resolveGeneratedDir(ns, ts.paths, ts.baseUrl) : null; + if (loc && existsSync(loc.dir)) sdkCache.set(ns, { dir: loc.dir, sdk: collectSdk(loc.dir) }); + else sdkCache.set(ns, { dir: loc?.dir ?? null, sdk: null }); + } + const cached = sdkCache.get(ns); + nsEntry.generatedDir = cached.dir; + + // A missing generated dir (alias unresolved or the resolved dir absent) is + // a fundamental, op-independent failure — the namespace's SDK doesn't exist + // at all, so nothing the block imports can resolve. Surface it as exit 1 + // here so the import-presence op gate below (which would otherwise mark + // every op backend-pending for a block that imports none of them) cannot + // mask a wholly-missing SDK. The human report already names the missing + // namespace and prints the `cnc codegen` remediation. + if (!cached.sdk) failed = true; + + const checkOp = (op, kind, expected, present) => { + const satisfied = !!cached.sdk && present; + const declaredPending = req.pending.has(op); + // Import-presence gate (§9): is the symbol this op maps to actually + // imported from `@/generated/*` somewhere in the host source? Models map + // to an accessor object, not a hook — a list block imports the hook, not + // the model name — so only mutation/query hooks are import-gated; a + // declared-but-unimported model is treated the same (informational). + const imported = kind === 'model' ? false : importedSymbols.has(expected); + // A missing op that the block does NOT import is backend-pending: the + // block declared the full capability surface but degrades to the ops it + // wires (e.g. org-members-list declares removeOrgMember/transferOrgOwnership + // yet imports neither). Reported, never a failure. Only a missing op the + // block GENUINELY IMPORTS (a real compile-against-a-missing-export) — or + // a missing op when the SDK dir itself is absent — flips `failed`. An + // explicit `pending` declaration also suppresses the failure. + const pending = declaredPending || (!satisfied && !imported); + if (!satisfied && imported && !declaredPending) failed = true; + nsEntry.ops.push({ op, kind, expects: expected, ok: satisfied, pending, imported, declaredPending }); + }; + + for (const op of req.mutations) checkOp(op, 'mutation', mutationHook(op), cached.sdk?.exports.has(mutationHook(op))); + for (const op of req.queries) checkOp(op, 'query', queryHook(op), cached.sdk?.exports.has(queryHook(op))); + // Model accessors are SINGULAR on disk; normalise the declared name (which + // may be plural) through the same singulariser used to key the SDK, then + // fall back to an exact export match for non-standard shapes. + for (const mdl of req.models) + checkOp(mdl, 'model', `models/${singularizeModel(mdl)}`, cached.sdk?.modelKeys.has(singularizeModel(mdl)) || cached.sdk?.exports.has(mdl)); + + blockEntry.namespaces.push(nsEntry); + } + report.push(blockEntry); + } + + const runtimeOk = runtimeMounted(opts.project); + // Contract-preflight advisories: known arg-domain + defective/RLS-blocked ops + // touched by the installed blocks. WARN-only — they NEVER change `failed`. + const warnings = collectContractWarnings(report, importedSymbols, opts.project); + + if (opts.json) { + console.log(JSON.stringify({ project: opts.project, aliasOk, runtimeMounted: runtimeOk, blocks: report, warnings, ok: !failed }, null, 2)); + process.exit(failed ? 1 : 0); + } + + // human report + console.log(C.bold(`\nConstructive blocks — SDK preflight (${opts.project})\n`)); + console.log(`${aliasOk ? C.green('✓') : C.red('✗')} @/generated/* alias in tsconfig`); + const missingNs = new Set(); + for (const b of report) { + console.log(`\n${C.bold(b.block)}`); + for (const ns of b.namespaces) { + const dirOk = !!ns.generatedDir && existsSync(ns.generatedDir); + console.log( + ` namespace ${C.bold(ns.namespace)} ${dirOk ? C.green('✓') : C.red('✗')} ${C.dim(ns.generatedDir ?? '(unresolved — alias missing)')}` + ); + if (!dirOk) missingNs.add(ns.namespace); + for (const o of ns.ops) { + // pending + absent → ◦ (informational); pending + present → ✓; else ✓/✗. + const mark = o.ok ? C.green('✓') : o.pending ? C.dim('◦') : C.red('✗'); + // Distinguish WHY an absent op is informational: an explicit `pending` + // declaration vs detected as declared-but-not-imported (the block + // degrades — it never imports this op's hook, so it cannot fail to + // compile against it). + const why = o.declaredPending ? 'backend-pending — not yet deployed' : 'declared, not imported — block degrades (backend-pending)'; + const note = o.pending && !o.ok ? C.dim(` (${why})`) : ''; + console.log(` ${mark} ${o.kind} ${C.bold(o.op)} ${C.dim(`→ ${o.expects}`)}${note}`); + } + } + } + console.log(`\n${runtimeOk ? C.green('✓') : C.dim('•')} ${runtimeOk ? 'mounted' : 'not found (mount it once at the app root — advisory)'}`); + + // Contract advisories (WARN, never a failure). These name an op that exists + + // type-checks but has a known runtime arg-domain or upstream defect, with the + // safe value / known behavior — so the build doesn't burn a round-trip on + // INVALID_ACCESS_LEVEL or a silent no-op. Mirrors SKILL.md "Known SDK gaps". + if (warnings.length) { + console.log(C.bold(`\n⚠ ${warnings.length} contract advisor${warnings.length === 1 ? 'y' : 'ies'} (WARN — not a failure):`)); + for (const w of warnings) { + const where = w.block === '(imported)' ? C.dim('(imported in host source)') : `${C.bold(w.block)} ${C.dim(`/ ${w.namespace}`)}`; + console.log(` ${C.bold('⚠')} ${where}\n ${w.message}`); + } + } + + if (failed) { + console.log(C.red('\n✗ Unsatisfied prerequisites.')); + if (missingNs.size) { + const names = [...missingNs].join(','); + console.log( + `\nGenerate the missing SDK(s), then re-run this check:\n ${C.bold(`cnc codegen --api-names ${names} --react-query --orm -o src/generated`)}\n` + + C.dim(' (or per-endpoint: cnc codegen --endpoint https://./graphql --react-query --orm -o src/generated/)') + ); + } else { + console.log( + `\nThe SDK exists but is missing operations above — the host backend likely hasn't deployed them, or the SDK is stale. Re-generate and check drift:\n ${C.bold('cnc codegen --api-names --react-query --orm -o src/generated')}\n ${C.bold('cnc codegen … --dry-run')} ${C.dim('# drift check')}` + ); + } + process.exit(1); + } + + const pendingSeams = report.flatMap((b) => b.namespaces.flatMap((n) => n.ops.filter((o) => o.pending && !o.ok).map((o) => o.op))); + console.log(C.green('\n✓ All data-block prerequisites satisfied.')); + if (pendingSeams.length) { + console.log(C.dim(` (${pendingSeams.length} backend-pending seam(s): ${[...new Set(pendingSeams)].join(', ')} — declared or imported-degraded; the block's GA path stands alone until those procs ship.)`)); + } + process.exit(0); +} + +main(); diff --git a/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs new file mode 100644 index 0000000..b180c68 --- /dev/null +++ b/.agents/skills/constructive-blocks/scripts/check-sdk.test.mjs @@ -0,0 +1,460 @@ +#!/usr/bin/env node +/** + * Focused tests for check-sdk.mjs — the plural↔singular model normalisation + * and the declared-backend-pending op handling (F12). + * + * Zero deps, Node ≥18 built-in test runner: + * + * node --test .agents/skills/constructive-blocks/scripts/check-sdk.test.mjs + * + * Each case builds a throwaway host app (tsconfig + a tiny generated SDK whose + * model files are SINGULAR, mirroring the real ORM on-disk shape) plus a + * manifest, then runs check-sdk.mjs as a child process and asserts the exit + * code + a couple of report lines. The invariant under test is the SDK Binding + * Contract's "make BOTH-correct" rule: a manifest may declare a list model in + * the plural (`orgMemberships`) or singular (`orgMembership`) and either must + * satisfy the singular on-disk `models/orgMembership.ts`. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'check-sdk.mjs'); + +// Build a host-app fixture. `models` are SINGULAR file basenames (as codegen +// emits); `hooks` are the generated hook identifiers that exist. +function makeApp({ models = [], hooks = [], manifest }) { + const root = mkdtempSync(join(tmpdir(), 'check-sdk-')); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/generated/*': ['./src/generated/*'] } } }) + ); + const modelsDir = join(root, 'src/generated/admin/orm/models'); + const hooksDir = join(root, 'src/generated/admin/hooks/mutations'); + mkdirSync(modelsDir, { recursive: true }); + mkdirSync(hooksDir, { recursive: true }); + for (const m of models) writeFileSync(join(modelsDir, `${m}.ts`), `export class ${m[0].toUpperCase()}${m.slice(1)}Model {}\n`); + for (const h of hooks) writeFileSync(join(hooksDir, `${h}.ts`), `export function ${h}() {}\n`); + const manifestDir = join(root, 'src/.constructive/blocks'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync(join(manifestDir, 'block.requires.json'), JSON.stringify(manifest)); + return root; +} + +function run(root) { + const r = spawnSync(process.execPath, [SCRIPT, '--project', root], { encoding: 'utf-8' }); + return { code: r.status, out: r.stdout + r.stderr }; +} + +const GA_HOOKS = ['useUpdateOrgMembershipMutation', 'useDeleteOrgMembershipMutation']; + +test('plural manifest model matches singular on-disk accessor (exit 0)', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, + manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'deleteOrgMembership'], queries: [], models: ['orgMemberships'] } + }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.match(out, /model orgMemberships → models\/orgMembership/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('singular manifest model also matches (BOTH-correct, exit 0)', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, + manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'deleteOrgMembership'], queries: [], models: ['orgMembership'] } + }); + try { + assert.equal(run(root).code, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('-ies plural normalises to -y (identities → identity)', () => { + const root = makeApp({ + models: ['identity'], + hooks: [], + manifest: { namespace: 'admin', mutations: [], queries: [], models: ['identities'] } + }); + try { + assert.equal(run(root).code, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('declared-pending op is informational, not a failure (exit 0)', () => { + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, // removeOrgMember / transferOrgOwnership intentionally absent + manifest: { + namespace: 'admin', + mutations: ['updateOrgMembership', 'deleteOrgMembership', 'removeOrgMember', 'transferOrgOwnership'], + queries: [], + models: ['orgMemberships'], + pending: ['removeOrgMember', 'transferOrgOwnership'] + } + }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + // Each declared-pending op is reported informationally (◦) as backend-pending, + // and the run summarises them as backend-pending seam(s) — but never fails. + assert.match(out, /removeOrgMember.*backend-pending/); + assert.match(out, /backend-pending seam/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a NON-pending missing op that IS IMPORTED still fails (exit 1) — binding still protects', () => { + // The import-presence gate (§9) hard-fails only on a missing op the block + // genuinely IMPORTS from @/generated/* (a real compile-against-a-missing-export); + // a declared-but-unimported op degrades (◦, exit 0). So to exercise the protection + // this fixture IMPORTS the missing hook in a source file. + const root = makeApp({ + models: ['orgMembership'], + hooks: GA_HOOKS, + manifest: { namespace: 'admin', mutations: ['updateOrgMembership', 'totallyMissingOp'], queries: [], models: ['orgMembership'] } + }); + // add a source file that imports the missing op's hook (triggers the hard-fail) + const blocksDir = join(root, 'src/blocks'); + mkdirSync(blocksDir, { recursive: true }); + writeFileSync( + join(blocksDir, 'uses-missing.tsx'), + "import { useTotallyMissingOpMutation } from '@/generated/admin';\nexport function X() { useTotallyMissingOpMutation({}); return null; }\n" + ); + try { + const { code, out } = run(root); + assert.equal(code, 1, out); + assert.match(out, /✗ mutation totallyMissingOp/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a pending op that IS present reports ✓, not suppressed (exit 0)', () => { + const root = makeApp({ + models: [], + hooks: ['useRemoveOrgMemberMutation'], + manifest: { namespace: 'admin', mutations: ['removeOrgMember'], queries: [], models: [], pending: ['removeOrgMember'] } + }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.match(out, /✓ mutation removeOrgMember/); + assert.doesNotMatch(out, /backend-pending/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// CONTRACT PREFLIGHT — known arg-domain + defective/RLS-blocked op advisories. +// +// These assert the WARN-only contract layer: an op that EXISTS (passes the +// binding gate) but has a known runtime arg-domain (createApiKey.accessLevel) or +// upstream defect (sendVerificationEmail GAP-9, revokeApiKey GAP-3, createUser +// GAP-6, sessions GAP-2, …) must produce a `warnings[]` entry naming the GAP-N + +// safe value, WITHOUT changing the exit code. The advisory table mirrors +// SKILL.md "Known SDK gaps" and the harness PLATFORM-GAPS.md confirmed-live facts. +// +// `manifest` is written verbatim (so a test controls the namespace + declared +// ops). `hooks` are generated hook identifiers that EXIST (so the binding gate +// passes). `src` is an optional map of {relativePath: contents} written under +// src/ — used to exercise import-presence + arg-domain corroboration. +// --------------------------------------------------------------------------- +function makeContractApp({ ns = 'auth', hooks = [], manifest, src = {} }) { + const root = mkdtempSync(join(tmpdir(), 'check-sdk-contract-')); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/generated/*': ['./src/generated/*'] } } }) + ); + const hooksDir = join(root, `src/generated/${ns}/hooks/mutations`); + mkdirSync(hooksDir, { recursive: true }); + for (const h of hooks) writeFileSync(join(hooksDir, `${h}.ts`), `export function ${h}() {}\n`); + for (const [rel, contents] of Object.entries(src)) { + const p = join(root, 'src', rel); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, contents); + } + const manifestDir = join(root, 'src/.constructive/blocks'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync(join(manifestDir, 'block.requires.json'), JSON.stringify(manifest)); + return root; +} + +// Run with --json and parse the report (so we can assert on warnings[] structurally). +function runJson(root) { + const r = spawnSync(process.execPath, [SCRIPT, '--project', root, '--json'], { encoding: 'utf-8' }); + let json = null; + try { + json = JSON.parse(r.stdout); + } catch { + /* leave null — the assertion will surface stderr */ + } + return { code: r.status, json, out: r.stdout + r.stderr }; +} + +test('arg-domain: createApiKey accessLevel WARNs {read_only,full_access}, never fails (exit 0)', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useCreateApiKeyMutation'], // op EXISTS → binding gate passes + manifest: { namespace: 'auth', mutations: ['createApiKey'], queries: [], models: [] }, + src: { + // block hard-codes the BAD enum values → corroboration upgrades to 'confirmed' + 'blocks/auth/api-key-create-dialog.tsx': + "import { useCreateApiKeyMutation } from '@/generated/auth';\nconst accessLevelOptions = ['read', 'write', 'admin'];\nexport function D() { useCreateApiKeyMutation({ selection: { fields: { clientMutationId: true } } }); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); // WARN, NOT a failure + assert.ok(json, out); + assert.equal(json.ok, true); + const w = json.warnings.find((x) => x.id === 'createApiKey-accessLevel'); + assert.ok(w, `expected a createApiKey arg-domain warning, got ${JSON.stringify(json.warnings)}`); + assert.equal(w.kind, 'arg-domain'); + assert.equal(w.field, 'accessLevel'); + assert.deepEqual(w.safe, ['read_only', 'full_access']); + assert.deepEqual(w.bad, ['read', 'write', 'admin']); + assert.equal(w.confidence, 'confirmed'); // the bad literals were found in source + assert.match(w.message, /INVALID_ACCESS_LEVEL/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('defective: sendVerificationEmail WARNs GAP-9, exit 0 (op present, runtime aborts)', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useSendVerificationEmailMutation'], + manifest: { namespace: 'auth', mutations: ['sendVerificationEmail'], queries: [], models: [] } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + const w = json.warnings.find((x) => x.id === 'sendVerificationEmail-abort'); + assert.ok(w, out); + assert.equal(w.kind, 'defective'); + assert.equal(w.gap, 'GAP-9'); + assert.equal(w.via, 'declared'); // named in the manifest, hook not imported here + assert.match(w.message, /user_secrets_del/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('defective: createUser(type=2) / createOrganization WARN GAP-6 (RLS-denied)', () => { + const root = makeContractApp({ + ns: 'admin', + hooks: ['useCreateUserMutation'], + manifest: { namespace: 'admin', mutations: ['createUser'], queries: [], models: [] } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + const w = json.warnings.find((x) => x.id === 'createUser-org-rls'); + assert.ok(w, out); + assert.equal(w.gap, 'GAP-6'); + assert.match(w.message, /row-level security/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('defective: revokeApiKey imported WITHOUT a manifest entry still WARNs GAP-3 (import-presence)', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useRevokeApiKeyMutation', 'useSignInMutation'], + // manifest is for a DIFFERENT, clean op — revokeApiKey is only ever imported + manifest: { namespace: 'auth', mutations: ['signIn'], queries: [], models: [] }, + src: { + 'blocks/auth/keys.tsx': + "import { useRevokeApiKeyMutation } from '@/generated/auth';\nexport function K() { useRevokeApiKeyMutation({ selection: { fields: { clientMutationId: true } } }); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + const w = json.warnings.find((x) => x.id === 'revokeApiKey-noop'); + assert.ok(w, `expected revokeApiKey warning from an imported-only op, got ${JSON.stringify(json.warnings)}`); + assert.equal(w.via, 'imported'); + assert.equal(w.block, '(imported)'); + assert.equal(w.gap, 'GAP-3'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('no false positives: a clean GA block (signIn) emits ZERO contract advisories', () => { + const root = makeContractApp({ + ns: 'auth', + hooks: ['useSignInMutation'], + manifest: { namespace: 'auth', mutations: ['signIn'], queries: [], models: [] }, + src: { + 'blocks/auth/sign-in.tsx': + "import { useSignInMutation } from '@/generated/auth';\nexport function S() { useSignInMutation({ selection: { fields: { result: { select: { userId: true } } } } }); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + assert.equal(json.warnings.length, 0, `expected no advisories for a clean GA app, got ${JSON.stringify(json.warnings)}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('GAP-5 org-admin seams are NOT a contract advisory (left to the binding gate, no "backend-pending" WARN)', () => { + // removeOrgMember is an *absent* (not-deployed) op — the binding gate's + // pending/import-presence mechanism already surfaces it. The contract layer must + // NOT add a redundant WARN (which would also collide with the present-pending + // "doesNotMatch(/backend-pending/)" expectation when such an op IS deployed). + const root = makeContractApp({ + ns: 'admin', + hooks: ['useRemoveOrgMemberMutation'], // present → binding gate clean + manifest: { namespace: 'admin', mutations: ['removeOrgMember'], queries: [], models: [], pending: ['removeOrgMember'] } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 0, out); + assert.equal(json.warnings.length, 0, `GAP-5 ops must not appear in contract warnings, got ${JSON.stringify(json.warnings)}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a contract advisory does NOT mask a real binding failure (exit 1 still wins)', () => { + // createApiKey present (so its WARN fires) + a genuinely-missing imported op. + const root = makeContractApp({ + ns: 'auth', + hooks: ['useCreateApiKeyMutation'], // present + manifest: { namespace: 'auth', mutations: ['createApiKey', 'reallyMissingOp'], queries: [], models: [] }, + src: { + // import BOTH: createApiKey (warns) AND reallyMissingOp (hard-fail — imported but absent) + 'blocks/auth/x.tsx': + "import { useCreateApiKeyMutation, useReallyMissingOpMutation } from '@/generated/auth';\nexport function X() { useCreateApiKeyMutation({}); useReallyMissingOpMutation({}); return null; }\n" + } + }); + try { + const { code, json, out } = runJson(root); + assert.equal(code, 1, out); // binding failure dominates + assert.equal(json.ok, false); + // the WARN is still recorded (advisory layer runs regardless of failure) + assert.ok(json.warnings.some((x) => x.id === 'createApiKey-accessLevel'), out); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// WORKSPACE-ROOT RESOLUTION — --project accepts the workspace root (the dir +// holding packages/, the same the scaffolders take) and derives the +// app package (packages/app | app) internally. The 5-app run hit +// `check-sdk --project ` → "No data-block manifests" because +// the manifests + tsconfig actually live under packages/app, not the root. +// +// Build a WORKSPACE ROOT whose app package sits one level down (`packages/app` +// by default, or a root-level `app/`). The root itself carries package.json + +// tsconfig.json but NO src/ — exactly the real pgpm/lerna workspace shape that +// must NOT be mistaken for the app package. +// --------------------------------------------------------------------------- +function makeWorkspace({ appSub = 'packages/app', hooks = ['useSignInMutation'], manifest = { namespace: 'auth', mutations: ['signIn'], queries: [], models: [] } } = {}) { + const root = mkdtempSync(join(tmpdir(), 'check-sdk-ws-')); + // Workspace-root markers: a package.json + tsconfig.json but deliberately NO + // src/ (so isAppPackage() rejects the root and derives the nested package). + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'ws-root', private: true })); + writeFileSync(join(root, 'tsconfig.json'), JSON.stringify({ files: [] })); + writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + const appDir = join(root, appSub); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'app' })); + writeFileSync(join(appDir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/generated/*': ['./src/generated/*'] } } })); + const hooksDir = join(appDir, 'src/generated/auth/hooks/mutations'); + mkdirSync(hooksDir, { recursive: true }); + for (const h of hooks) writeFileSync(join(hooksDir, `${h}.ts`), `export function ${h}() {}\n`); + const manifestDir = join(appDir, 'src/.constructive/blocks'); + mkdirSync(manifestDir, { recursive: true }); + writeFileSync(join(manifestDir, 'block.requires.json'), JSON.stringify(manifest)); + return { root, appDir }; +} + +test('workspace root --project resolves packages/app and finds manifests (exit 0)', () => { + const { root, appDir } = makeWorkspace(); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + // the derivation notice (stderr) names the resolved app package + the root + assert.match(out, /resolved app package/); + assert.ok(out.includes(appDir), out); + // the manifest under packages/app/src was actually checked + assert.match(out, /✓ mutation signIn → useSignInMutation/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('workspace root --project with a root-level app/ layout also resolves (exit 0)', () => { + const { root, appDir } = makeWorkspace({ appSub: 'app' }); + try { + const { code, out } = run(root); + assert.equal(code, 0, out); + assert.ok(out.includes(appDir), out); + assert.match(out, /✓ mutation signIn/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('--json on a workspace root keeps stdout PURE JSON (resolution notice → stderr)', () => { + const { root, appDir } = makeWorkspace(); + try { + const r = spawnSync(process.execPath, [SCRIPT, '--project', root, '--json'], { encoding: 'utf-8' }); + assert.equal(r.status, 0, r.stdout + r.stderr); + // stdout must parse cleanly — the "resolved app package" notice must NOT leak into it + const json = JSON.parse(r.stdout); + assert.equal(json.ok, true); + assert.equal(json.project, appDir); // report reflects the DERIVED package + assert.match(r.stderr, /resolved app package/); // notice went to stderr + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('an explicit app package dir is still used as-is (no derivation, no notice)', () => { + // Back-compat: pointing --project AT the app package (not the workspace root) + // must behave exactly as before — used verbatim, with no resolution notice. + const { root, appDir } = makeWorkspace(); + try { + const { code, out } = run(appDir); + assert.equal(code, 0, out); + assert.doesNotMatch(out, /resolved app package/); + assert.match(out, /✓ mutation signIn/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('an unresolvable --project (no app package, no tsconfig) fails loudly (exit 2)', () => { + const dir = mkdtempSync(join(tmpdir(), 'check-sdk-bare-')); + try { + const { code, out } = run(dir); + assert.equal(code, 2, out); + assert.match(out, /No app package found at or under/); + assert.match(out, /packages\/app/); // names the dirs it probed + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/.agents/skills/constructive-blueprints/references/blueprint-definition-format.md b/.agents/skills/constructive-blueprints/references/blueprint-definition-format.md index 9e023d6..ed21d8a 100644 --- a/.agents/skills/constructive-blueprints/references/blueprint-definition-format.md +++ b/.agents/skills/constructive-blueprints/references/blueprint-definition-format.md @@ -285,7 +285,7 @@ All 28 node types from the `node_type_registry`: | `LimitCounter` | Attaches increment/decrement triggers to track metered usage against configurable maximums. On INSERT the named limit is incremented; on DELETE it is decremented. | `limit_name` (required — must match a `limit_defaults` entry, e.g. `'projects'`, `'members'`), `scope` (default `'app'` — `'app'` for membership_type=1 or `'org'` for membership_type=2), `actor_field` (default `'owner_id'` — column-ref, field on target table holding the actor/entity ID), `events` (default `['INSERT','DELETE']` — which DML events to attach triggers for) | | `LimitFeatureFlag` | Gates a table behind a feature flag backed by cap tables. Attaches a BEFORE INSERT trigger that checks `resolve_cap(feature_name) > 0`. Features are modeled as caps with `max=0` (disabled) or `max=1` (enabled) in `limit_caps_defaults`. | `feature_name` (required — cap name, must match a `limit_caps_defaults` entry), `scope` (default `'app'` — `'app'` or `'org'`), `entity_field` (default `'entity_id'` — column-ref, used for org-scope only to resolve per-entity cap overrides) | -**Prerequisites:** Both require `limits_module` to be provisioned for the target scope. Enable via `modules:['all']` or the `b2b`/`full` presets, or via `has_limits: true` on entity types. +**Prerequisites:** Both require `limits_module` to be provisioned for the target scope. Add `'limits_module:app'` (and/or `'limits_module:org'`) to your explicit module list — it ships in the `auth:email`, `b2b`, and `full` preset lists — or set `has_limits: true` on entity types. (Do not use `modules:['all']`; it is not a sentinel and installs nothing.) **Example — limit projects per org:** ```json @@ -325,7 +325,7 @@ Seed `limit_caps_defaults` with `{ name: 'advanced_reporting', max: 1 }` to enab |-----------|---------|----------------| | `DataI18n` | Creates a `{table}_translations` table with FK, `lang_code`, and copies of translatable fields. Unique constraint on `(parent_fk, lang_code)`. When `search` is provided, creates a SearchFullText tsvector on the translations table with dynamic per-row language stemming (30+ languages out of the box). | `fields` (required — array of field names to make translatable), `search` (optional — SearchFullText config, auto-sets `lang_column: 'lang_code'` for dynamic stemming) | -**Prerequisites:** Requires `i18n_module` to be provisioned. Install via `modules:['all']`, the `full` preset, or add `'i18n_module'` to your module list. +**Prerequisites:** Requires `i18n_module` to be provisioned. Add `'i18n_module'` to your explicit module list — it ships in the `full` preset list. (Do not use `modules:['all']`; it is not a sentinel and installs nothing.) For full documentation including ORM queries, GraphQL localeStrings, and SQL search patterns, see [`constructive-i18n`](../constructive-i18n/SKILL.md). @@ -370,7 +370,7 @@ Each translation row is stemmed in its own language — insert with `lang_code = |-----------|---------|----------------| | `DataRealtime` | Creates a per-table subscriber table in `subscriptions_public` with RLS policies derived from source table SELECT policies. Attaches statement-level `emit_change()` triggers to track changes. Requires `realtime_module`. | `operations` (default `['INSERT', 'UPDATE', 'DELETE']` — which DML operations to track), `subscriber_table_name` (default `'{source_table}_subscriber'`) | -**Prerequisites:** Requires `realtime_module` to be provisioned. Enable via `modules:['all']` or the `full` preset, or add `'realtime_module'` to your module list. +**Prerequisites:** Requires `realtime_module` to be provisioned. Add `'realtime_module'` to your explicit module list. (No shipped preset includes it by default, and there is no `modules:['all']` sentinel — it installs nothing.) **Example — enable realtime on a messages table:** ```json diff --git a/.agents/skills/constructive-blueprints/references/module-presets.md b/.agents/skills/constructive-blueprints/references/module-presets.md index 21cf9ec..f13d11c 100644 --- a/.agents/skills/constructive-blueprints/references/module-presets.md +++ b/.agents/skills/constructive-blueprints/references/module-presets.md @@ -74,7 +74,7 @@ Provisions shared infrastructure for realtime subscriptions: - **Partitioned `change_log` table** — durable, time-partitioned event stream for change tracking. Uses PostgreSQL native range partitioning with automatic partition lifecycle management (creation, rotation, cleanup) - **`emit_change()` trigger function** — called by statement-level triggers on source tables to record changes and emit NOTIFY signals -**Included in:** `full` preset (via `['all']` sentinel). Not included in other presets by default — add `'realtime_module'` to your module list to enable. +**Included in:** the `full` preset's explicit module list. Not included in other presets by default — add `'realtime_module'` to your module list to enable. (There is no `['all']` sentinel; `full` is itself an explicit list.) **Runtime toggle:** `database_settings.enable_realtime` and `api_settings.enable_realtime` control whether the server activates realtime processing. API setting takes precedence over database setting. @@ -88,7 +88,7 @@ Provisions device tracking, trusted device MFA bypass, and device approval gate: - **`auth_user_devices` table** — per-user device records (token hash, IP, user agent, trust/approval status) - **`approve_device` procedure** — validates email approval tokens for the device approval flow -**Included in:** `full` preset (via `['all']` sentinel). Not included in other presets by default — add `'devices_module'` to your module list to enable. +**Included in:** the `b2b` and `full` presets' explicit module lists. Not included in other presets by default — add `'devices_module'` to your module list to enable. (There is no `['all']` sentinel.) **Settings toggles:** All features are off by default (`enable_device_tracking = true` enables passive tracking only). Enable `enable_trusted_devices` for MFA bypass, `require_device_approval` for email approval gate, `require_mfa_new_device` to force MFA on new devices. @@ -114,7 +114,7 @@ Defaults: 768 dimensions, 1000 chunk_size, 200 chunk_overlap, `"paragraph"` stra **Scoping:** Supports `scope` option (`"app"`, `"org"`, etc.) for entity-level provisioning. The `generate:constructive` reference DB uses `["agent_module", {"has_plans": true, "has_resources": true, "has_agents": true, "scope": "org"}]`. -**Included in:** `full` preset (via `['all']` sentinel). Not included in other presets by default — add the desired variant to your module list. +**Included in:** the `full` preset's explicit module list. Not included in other presets by default — add the desired variant to your module list. (There is no `['all']` sentinel; `full` is itself an explicit list.) **Note:** The old `has_knowledge` and `has_skills` flags are replaced by `has_resources`. The unified `agent_resource` table covers both via the `kind` column. diff --git a/.agents/skills/constructive-data-modeling/SKILL.md b/.agents/skills/constructive-data-modeling/SKILL.md index 0159e47..fd49b9d 100644 --- a/.agents/skills/constructive-data-modeling/SKILL.md +++ b/.agents/skills/constructive-data-modeling/SKILL.md @@ -23,8 +23,8 @@ Use this skill when: ## The Composition Flow ``` -1. Provision database → db.databaseProvisionModule.create({ modules: ['all'] }) -2. Create table → db.secureTableProvision.create({ tableName, nodeType, ... }) +1. Provision database → db.databaseProvisionModule.create({ modules: [...explicit list] }) +2. Create table → db.secureTableProvision.create({ tableName, nodes:[...], grants:[...], policies:[...] }) 3. Add fields → db.field.create({ tableId, name, type, ... }) 4. Add constraints → db.checkConstraint.create / db.foreignKeyConstraint.create 5. Add indexes → db.index.create({ tableId, fieldIds, ... }) @@ -34,38 +34,56 @@ Use this skill when: ## Database Provisioning +Pass an **explicit module list** (never `modules: ['all']` — `'all'` is not a sentinel; it matches zero branches in `provision_database_modules` and installs nothing, silently breaking auth + RLS). The list below is the verified `auth:email` default for a basic auth app. + ```typescript +// auth:email preset. Source: node-type-registry/src/module-presets/auth-email.ts +const modules = [ + 'users_module', 'membership_types_module', + 'permissions_module:app', 'limits_module:app', 'levels_module:app', + 'memberships_module:app', 'sessions_module', 'user_state_module', + 'config_secrets_user_module', 'emails_module', 'rls_module', 'user_auth_module', +]; + const result = await db.databaseProvisionModule.create({ data: { databaseName: 'my-app', ownerId: userId, subdomain: 'my-app', domain: 'localhost', - modules: ['all'], + modules, bootstrapUser: true, }, select: { id: true, databaseId: true, status: true }, }).execute(); ``` -See [provisioning.md](./references/provisioning.md) for the full provisioning flow. +See [provisioning.md](./references/provisioning.md) for the full provisioning flow, the `b2b`/`full` lists, and why `['all']` is wrong. ## Tables -Create tables via `secureTableProvision` (recommended) or `db.table.create`: +Create tables via `secureTableProvision` (recommended) or `db.table.create`. The input is the **Blueprint shape** — independent `nodes[]` / `fields[]` / `grants[]` / `policies[]` arrays (each entry discriminated by `$type`), **not** the flat `nodeType` / `grantRoles` / `policyType` shape (that is stale and no longer matches the live platform). See [`constructive-security`](../constructive-security/SKILL.md) for the full reference. ```typescript await db.secureTableProvision.create({ data: { databaseId, tableName: 'projects', - nodeType: 'DataEntityMembership', useRls: true, - grantRoles: ['authenticated'], - grantPrivileges: [['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*']] as unknown as Record, - policyType: 'AuthzEntityMembership', - policyPermissive: true, - policyData: { entity_field: 'entity_id', membership_type: 2 }, + nodes: [ + { $type: 'DataEntityMembership' }, + ] as unknown as Record, + grants: [ + { roles: ['authenticated'], privileges: [['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*']] }, + ] as unknown as Record, + policies: [ + { + $type: 'AuthzEntityMembership', + permissive: true, + privileges: ['select', 'insert', 'update', 'delete'], + data: { entity_field: 'entity_id', membership_type: 2 }, + }, + ] as unknown as Record, }, select: { id: true, tableId: true, outFields: true }, }).execute(); diff --git a/.agents/skills/constructive-data-modeling/references/provisioning.md b/.agents/skills/constructive-data-modeling/references/provisioning.md index 3f52107..b8c7cb2 100644 --- a/.agents/skills/constructive-data-modeling/references/provisioning.md +++ b/.agents/skills/constructive-data-modeling/references/provisioning.md @@ -3,8 +3,8 @@ ## Client Setup ```typescript -import { createClient as createAuthClient } from '@constructive-db/sdk/auth'; -import { createClient as createPublicClient } from '@constructive-db/sdk/public'; +import { createClient as createAuthClient } from '@constructive-io/sdk/auth'; +import { createClient as createPublicClient } from '@constructive-io/sdk/public'; const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' }); const publicDb = createPublicClient({ endpoint: 'http://api.localhost:3000/graphql' }); @@ -13,7 +13,7 @@ const publicDb = createPublicClient({ endpoint: 'http://api.localhost:3000/graph ## Step 1: Sign Up + Sign In ```typescript -await authDb.mutation.signUp({ input: { email, password } }, { select: { ok: true, errors: true } }).execute(); +await authDb.mutation.signUp({ input: { email, password } }, { select: { result: { select: { id: true } } } }).execute(); const signIn = await authDb.mutation.signIn( { input: { email, password } }, @@ -24,18 +24,38 @@ const { accessToken, userId } = signIn.signIn.result; ## Step 2: Provision Database -Always use `modules: ['all']` and `bootstrapUser: true`: +Pass an **explicit module list** and `bootstrapUser: true`. **Never use `modules: ['all']`** — `'all'` is not a sentinel. `databaseProvisionModule` feeds `modules` straight into `metaschema_generators.provision_database_modules`, whose body is ~58 branches of `IF '' = ANY(v_modules) THEN ...` with **no `'all'` expansion** anywhere (not in the SQL, the trigger, the SDK, or the CLI). So `['all']` matches nothing, installs zero optional modules, and you get only the ~4 base schemas. The damage is silent: `bootstrapUser` fails with `TARGET_USERS_NOT_FOUND`, per-DB `signIn`/`signUp`/`currentUser` are empty, and every app-public query hits an RLS denial. + +For a basic auth app (email/password + app-level RLS, no orgs/SSO/MFA), use the `auth:email` module list — the verified default: ```typescript publicDb.setHeaders({ Authorization: `Bearer ${accessToken}` }); +// auth:email — verified default for a basic auth app. +// Source of truth: constructive/packages/node-type-registry/src/module-presets/auth-email.ts +// (or: getModulePreset('auth:email').modules from @constructive-io/node-type-registry) +const modules = [ + 'users_module', + 'membership_types_module', + 'permissions_module:app', + 'limits_module:app', + 'levels_module:app', + 'memberships_module:app', + 'sessions_module', + 'user_state_module', + 'config_secrets_user_module', + 'emails_module', + 'rls_module', + 'user_auth_module', +]; + const result = await publicDb.databaseProvisionModule.create({ data: { databaseName: dbName, ownerId: userId, subdomain: dbName, domain: 'localhost', - modules: ['all'], + modules, bootstrapUser: true, }, select: { id: true, databaseId: true, databaseName: true, status: true } @@ -44,6 +64,8 @@ const result = await publicDb.databaseProvisionModule.create({ const dbId = result.createDatabaseProvisionModule?.databaseProvisionModule?.databaseId; ``` +For a fuller app, swap in the `b2b` module list (orgs/teams/invites/permissions) or `full` (every standard module). Pull the exact array from the matching `module-presets/.ts` file. See the `constructive-platform` skill's `module-presets.md` for the full catalog. + ## Step 3: Apply Workarounds See `workarounds/fix-membership-defaults` and `workarounds/auto-verify-email`. @@ -76,7 +98,14 @@ await db.notes.create({ data: { content: 'Hello' }, select: { id: true } }).exec ## Module Reference +Always pass an explicit module list — the array is what `provision_database_modules` matches against. There is **no `['all']` sentinel**; passing it installs nothing (see Step 2). + | Modules | What it installs | |---|---| -| `['all']` | Everything — always use this for demos and real apps | -| `['uuid_module', 'users_module']` | Minimal — breaks app API auth | +| `auth:email` list (above) | Verified default — email/password auth + app-level RLS. Use for a basic auth app. | +| `b2b` list | `auth:email` + orgs/teams/invites/fine-grained permissions/levels/profiles/hierarchy. Multi-tenant SaaS. | +| `full` list | Every standard module (`b2b` + storage, billing/plans, notifications, ...). Reference/demo DBs. | +| `['users_module']` only | Minimal — breaks app API auth (no RLS/memberships/auth procedures). | +| `['all']` | **WRONG / anti-pattern** — not a sentinel; matches zero branches, installs nothing, silently breaks auth + RLS. | + +Source of truth for every list: `constructive/packages/node-type-registry/src/module-presets/.ts` (the `ModulePreset.modules` field), or `getModulePreset(name).modules` from `@constructive-io/node-type-registry`. diff --git a/.agents/skills/constructive-frontend/SKILL.md b/.agents/skills/constructive-frontend/SKILL.md index c00b4e7..b6a5e4e 100644 --- a/.agents/skills/constructive-frontend/SKILL.md +++ b/.agents/skills/constructive-frontend/SKILL.md @@ -20,6 +20,8 @@ Use this skill when: - Setting up theming, dark mode, OKLCH tokens - Using the shadcn registry for Constructive components +This skill is for **YOUR domain-entity CRUD** — UI over any business table, via CRUD Stack + `_meta` meta-forms. For **auth/account/org/shell** capability UI (sign-in, password reset, MFA, membership, invites) use **`constructive-blocks`** (the flow catalog) instead. + ## UI Components 50+ components on Base UI + Tailwind CSS v4 with cva variants and data-slot architecture. @@ -80,6 +82,7 @@ See [meta-forms.md](./references/meta-forms.md) for DynamicFormCard, locked FK p ## Cross-References +- `constructive-blocks` — auth/account/org/shell capability UI (copy-in blocks + flow catalog); use it for those bundles, this skill for domain-entity CRUD over any table. - `constructive-codegen` — Code generation and SDK usage (data fetching for components) - `pgpm` — Starter kits and Next.js app boilerplate (uses these UI components) — in [constructive-io/constructive](https://github.com/constructive-io/constructive) - `constructive-platform` — Platform core, server configuration diff --git a/.agents/skills/constructive-security/SKILL.md b/.agents/skills/constructive-security/SKILL.md index 96c0f2f..88f2a21 100644 --- a/.agents/skills/constructive-security/SKILL.md +++ b/.agents/skills/constructive-security/SKILL.md @@ -78,34 +78,46 @@ Use `AuthzComposite` only when flat composition is insufficient (e.g., `(A AND B ## SDK: `secureTableProvision` (Recommended) -One call to create fields, grants, policies, and enable RLS: +One call to create fields, grants, policies, and enable RLS. The input is the **Blueprint shape**: four independent, optional arrays — +- `nodes[]` — Data* field modules, each `{ $type: 'Data…' }` (optional `data`) +- `fields[]` — explicit columns (`{ name, type, is_required }`, **snake_case**; `type` accepts a `FieldType` object or a legacy bare string) +- `grants[]` — per-role privilege targeting (`{ roles, privileges }`, privileges are `[privilege, columns]` tuples) +- `policies[]` — Authz* RLS policies (`{ $type, permissive, privileges, data }`) +- `useRls: true` — enable RLS -```typescript -const grant_privileges = [ - ['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*'], -] as unknown as Record; - -const policy_data: Record = { - entity_field: 'entity_id', - membership_type: 2, -}; +> **The flat `nodeType` / `grantRoles` / `grantPrivileges` / `policyType` / `policyData` / `policyPermissive` shape is stale** and no longer matches the live platform. The generated `CreateSecureTableProvisionInput` exposes only `nodes` / `fields` / `grants` / `policies` / `useRls`. Use the arrays below. +```typescript await db.secureTableProvision.create({ data: { databaseId: '', tableName: 'projects', - nodeType: 'DataEntityMembership', useRls: true, - grantRoles: ['authenticated'], - grantPrivileges: grant_privileges, - policyType: 'AuthzEntityMembership', - policyPermissive: true, - policyData: policy_data, + // nodes[]: one entry per Data* field module (compose several in one call) + nodes: [ + { $type: 'DataEntityMembership' }, + ] as unknown as Record, + // grants[]: each entry = roles + a privilege list of [privilege, columns] tuples. + // '*' = all columns; an array restricts the columns that privilege applies to. + grants: [ + { roles: ['authenticated'], privileges: [['select', '*'], ['insert', '*'], ['update', '*'], ['delete', '*']] }, + ] as unknown as Record, + // policies[]: one entry per Authz* policy, discriminated by $type + policies: [ + { + $type: 'AuthzEntityMembership', + permissive: true, + privileges: ['select', 'insert', 'update', 'delete'], + data: { entity_field: 'entity_id', membership_type: 2 }, + }, + ] as unknown as Record, }, select: { id: true, tableId: true, outFields: true }, }).execute(); ``` +> **Casting note:** `fields[]` is typed `Record[]` (an array), so a field literal assigns directly with no cast. `nodes` / `grants` / `policies` are typed as a single `Record`, so each array literal needs `as unknown as Record` (as shown). + ### Paired Data Nodes | Policy Type | Data Node | Creates | diff --git a/CLAUDE.md b/CLAUDE.md index 358581a..c7dc2e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ A collection of skills for AI coding agents working with Constructive tooling. S | **constructive-flow-graphs** | Graph module + merkle store (SDK-authorable) with FBP spec links | | **constructive-i18n** | Internationalization — DataI18n, multilingual search, lang_column, i18n_module | | **constructive-frontend** | UI components (50+ on Base UI + Tailwind v4), CRUD Stack cards, meta-forms | +| **constructive-blocks** | Copy-in UI blocks (shadcn registry, `@constructive/`) that bind to the host's per-app generated GraphQL SDK — install/wire/author flow, `blocks-runtime`, `requires.json` manifests, bundled `check-sdk.mjs` preflight | | **constructive-codegen** | Code generation pipeline — config, templates, AST transforms, introspection | | **constructive-orm** | Generated ORM — query patterns, mutations, relations, pagination, _meta | | **constructive-hooks** | Generated React Query hooks — query/mutation hooks, cache, optimistic updates | diff --git a/README.md b/README.md index 9b51b39..9775739 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npx skills add constructive-io/constructive-skills --skill constructive-security ## Available Skills -Skills are organized into 20 umbrella skills. Each has a `SKILL.md` and a `references/` directory with detailed documentation. +Skills are organized into 21 umbrella skills. Each has a `SKILL.md` and a `references/` directory with detailed documentation. | Skill | Description | |-------|-------------| @@ -42,6 +42,7 @@ Skills are organized into 20 umbrella skills. Each has a `SKILL.md` and a `refer | `constructive-flow-graphs` | Graph module + merkle store (SDK-authorable) with FBP spec links | | `constructive-i18n` | Internationalization — DataI18n, multilingual search, lang_column, i18n_module | | `constructive-frontend` | UI components (50+ on Base UI + Tailwind v4), CRUD Stack cards, meta-forms | +| `constructive-blocks` | Copy-in UI blocks distributed via a shadcn registry (`@constructive/`) that bind to the host app's per-application generated GraphQL SDK. Install/wire/author flow, `blocks-runtime`, `requires.json` manifests, and a bundled `check-sdk.mjs` preflight that proves the host SDK exports every operation a block needs. | | `constructive-codegen` | Code generation pipeline — config, templates, AST transforms, introspection | | `constructive-orm` | Generated ORM — query patterns, mutations, relations, pagination, _meta | | `constructive-hooks` | Generated React Query hooks — query/mutation hooks, cache, optimistic updates | diff --git a/package.json b/package.json new file mode 100644 index 0000000..7fdaba5 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "constructive-skills", + "version": "1.0.0", + "private": true, + "description": "AI coding-agent skills for the Constructive tooling ecosystem.", + "scripts": { + "check:flows": "node .agents/skills/constructive-blocks/scripts/check-flows.mjs", + "test:sdk": "node --test .agents/skills/constructive-blocks/scripts/check-sdk.test.mjs" + } +}