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-oauth.zip b/.agents/skills/constructive-oauth.zip new file mode 100644 index 0000000..ebc7114 Binary files /dev/null and b/.agents/skills/constructive-oauth.zip differ diff --git a/.agents/skills/constructive-oauth/SKILL.md b/.agents/skills/constructive-oauth/SKILL.md new file mode 100644 index 0000000..8f074d7 --- /dev/null +++ b/.agents/skills/constructive-oauth/SKILL.md @@ -0,0 +1,329 @@ +--- +name: constructive-oauth +description: "OAuth/SSO configuration and Identity Providers management — configure social login, manage identity providers, debug OAuth flows. Use when asked to 'configure OAuth', 'setup SSO', 'identity provider', 'GitHub login', 'social login', 'OAuth not working', or when working with authentication providers." +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Constructive OAuth + +OAuth/SSO configuration and Identity Providers management. + +## When to Apply + +Use this skill when: +- Configuring OAuth identity providers (GitHub, Google, Apple, etc.) +- Debugging OAuth login flow (PROVIDER_NOT_CONFIGURED, EMAIL_NOT_VERIFIED) +- Managing auth settings (oauthEnabled, allowIdentitySignIn, etc.) +- Understanding identity providers and membership relationships + +## Architecture Overview + +``` +Identity Providers (App-level) + ├── github, google, apple... (social login) + └── Shared across all users +``` + +**Current Design**: Identity providers are configured at app-level, shared by all users. + +## Third-Party Provider Setup + +### Callback URL Format + +``` +Platform: https://auth.your-app.com/auth/callback/{provider} +Tenant: https://auth-{tenant-slug}.your-app.com/auth/callback/{provider} +``` + +**Platform vs Tenant**: Each has its own OAuth App registration with matching callback URL. + +### GitHub OAuth App + +1. Go to **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** + +2. Configure: + + **Platform**: + | Field | Value | + |-------|-------| + | Application name | Your App Name | + | Homepage URL | `https://your-app.com` | + | Authorization callback URL | `https://auth.your-app.com/auth/callback/github` | + + **Tenant**: + | Field | Value | + |-------|-------| + | Application name | Your App Name - {Tenant} | + | Homepage URL | `https://{tenant-slug}.your-app.com` | + | Authorization callback URL | `https://auth-{tenant-slug}.your-app.com/auth/callback/github` | + +3. Copy **Client ID** and generate **Client Secret** + +### Google OAuth + +1. Go to **Google Cloud Console → APIs & Services → Credentials → Create OAuth Client ID** + +2. Configure: + + **Platform**: + | Field | Value | + |-------|-------| + | Application type | Web application | + | Authorized redirect URIs | `https://auth.your-app.com/auth/callback/google` | + + **Tenant**: + | Field | Value | + |-------|-------| + | Application type | Web application | + | Authorized redirect URIs | `https://auth-{tenant-slug}.your-app.com/auth/callback/google` | + +3. Copy **Client ID** and **Client Secret** + +### Apple Sign In + +1. Go to **Apple Developer → Certificates, Identifiers & Profiles → Identifiers** + +2. Create App ID with Sign In with Apple capability + +3. Create Services ID: + + **Platform**: + | Field | Value | + |-------|-------| + | Return URLs | `https://auth.your-app.com/auth/callback/apple` | + + **Tenant**: + | Field | Value | + |-------|-------| + | Return URLs | `https://auth-{tenant-slug}.your-app.com/auth/callback/apple` | + +### URL Patterns + +OAuth flow uses the `auth` subdomain from `services_public.domains`: + +``` +Database schema: + domain: "localhost" | "your-app.com" + subdomain: "auth" | "auth-{tenant-slug}" | null +``` + +**Start OAuth Flow** (user clicks "Login with GitHub"): +``` +https://{subdomain}.{domain}/auth/oauth/{provider_slug}?redirect_uri={success_url} + +Platform: https://auth.your-app.com/auth/oauth/github?redirect_uri=/dashboard +Tenant: https://auth-{tenant-slug}.your-app.com/auth/oauth/github?redirect_uri=/dashboard +Local: https://auth.localhost:3000/auth/oauth/github?redirect_uri=/dashboard +``` + +Query parameters: +- `redirect_uri`: URL to redirect after successful login (default: `/`) + +**Callback URL** (configure in provider's developer console): +``` +https://{subdomain}.{domain}/auth/callback/{provider_slug} + +Platform: https://auth.your-app.com/auth/callback/github +Tenant: https://auth-{tenant-slug}.your-app.com/auth/callback/github +Local: https://auth.localhost:3000/auth/callback/github +``` + +**Note**: For tenant-specific subdomains, you may need to register a wildcard callback URL or multiple callback URLs in the provider's console. + +## REST API Endpoints + +API endpoints are accessed via the `auth` subdomain. The subdomain determines which tenant database to operate on: + +``` +Base URL: https://{auth-subdomain}.{domain} + +Platform: https://auth.your-app.com +Tenant: https://auth-{tenant-slug}.your-app.com +Local: https://auth.localhost:3000 +``` + +### Identity Providers + +``` +GET {base}/identity-providers # List all providers +GET {base}/identity-providers/:slug # Get single provider +PATCH {base}/identity-providers/:slug # Update provider config +POST {base}/identity-providers/:slug/rotate-secret # Rotate client secret +``` + +### Auth Settings + +``` +GET {base}/app-settings-auth # Get auth settings +PATCH {base}/app-settings-auth # Update auth settings +``` + +**Example** (local development): +```http +GET https://auth.localhost:3000/identity-providers +GET https://auth.localhost:3000/app-settings-auth +``` + +## Configuration Flow + +### 1. Create Identity Provider + +```http +POST /identity-providers +Content-Type: application/json + +{ + "slug": "github", + "kind": "oauth2", + "displayName": "GitHub", + "enabled": true, + "clientId": "", + "pkceEnabled": true +} +``` + +### 2. Set Client Secret + +```http +POST /identity-providers/github/rotate-secret +Content-Type: application/json + +{ + "clientSecret": "" +} +``` + +### 3. Configure Auth Settings + +```http +PATCH /app-settings-auth +Content-Type: application/json + +{ + "oauthEnabled": true, + "oauthRequireVerifiedEmail": false, + "allowIdentitySignIn": true, + "allowIdentitySignUp": true +} +``` + +### 4. Test Login + +``` +1. Start: GET https://auth.localhost:3000/auth/oauth/github +2. Redirect: → GitHub authorization page +3. Callback: → https://auth.localhost:3000/auth/callback/github +4. Result: → Session cookie set, user logged in +``` + +## Auth Settings Reference + +| Field | Type | Description | +|-------|------|-------------| +| `oauthEnabled` | boolean | Enable OAuth login globally | +| `oauthRequireVerifiedEmail` | boolean | Require verified email from IdP | +| `allowIdentitySignIn` | boolean | Allow existing users to sign in via OAuth | +| `allowIdentitySignUp` | boolean | Allow new user registration via OAuth | +| `oauthStateMaxAge` | interval | OAuth state token expiry | +| `oauthErrorRedirectPath` | string | Redirect path on OAuth error (default: `/auth/error`) | + +### Redirect Behavior + +| Scenario | Redirect To | +|----------|-------------| +| Success | `redirect_uri` query parameter (default: `/`) | +| Error | `oauthErrorRedirectPath` setting (default: `/auth/error`) | + +**Configure error redirect**: +```http +PATCH https://auth.localhost:3000/app-settings-auth +Content-Type: application/json + +{ + "oauthErrorRedirectPath": "/login?error=oauth" +} +``` + +Error redirect includes query params: `?error={code}&provider={slug}&error_description={msg}` + +Example error redirect: +``` +/login?error=oauth&error=EMAIL_NOT_VERIFIED&provider=github&error_description=Email+not+verified +``` + +## Common Issues + +### PROVIDER_NOT_CONFIGURED + +**Cause**: Provider not configured or cache not refreshed + +**Debug**: +```sql +SELECT slug, enabled, client_id, client_secret_id +FROM .identity_providers +WHERE slug = 'github'; +``` + +**Solution**: +- Ensure both `client_id` and `client_secret_id` are not NULL +- Wait 5 minutes for cache expiry, or restart GraphQL server + +### EMAIL_NOT_VERIFIED + +**Cause**: `oauth_require_verified_email = true` but user email not verified + +**Solution**: +```http +PATCH /app-settings-auth +Content-Type: application/json + +{ "oauthRequireVerifiedEmail": false } +``` + +### Cache Not Refreshing + +**Key Code** (`create-loader.ts`): +```typescript +const cache = new LRUCache({ + ttl: opts.ttlMs ?? DEFAULT_TTL_MS, + updateAgeOnGet: false, // Important: false = TTL from first set +}); +``` + +- `updateAgeOnGet: true` → TTL resets on every read, never expires +- `updateAgeOnGet: false` → TTL starts from first set, expires after 5 min + +**Identity providers loader TTL**: 5 minutes (`ttlMs: 5 * 60_000`) + +## Key Files + +| File | Description | +|------|-------------| +| `graphql/server/src/middleware/identity-providers.ts` | REST API middleware | +| `graphql/server/src/middleware/app-settings-auth.ts` | Auth settings middleware | +| `graphql/server/src/middleware/oauth.ts` | OAuth callback handler | +| `packages/express-context/src/loaders/identity-providers.ts` | Provider loader | +| `packages/express-context/src/loaders/create-loader.ts` | Loader factory | +| `constructive-db/.../identity_providers_module.sql` | DB schema generator | + +## Login Flow + +``` +OAuth Login + ↓ +sign_in_identity / sign_up_identity + ↓ +Create user (users table) + ↓ +Create app_membership (membership_type=1) + ↓ +Session cookie set → User logged in +``` + +## Cross-References + +- **Authentication basics:** [`constructive-auth`](../constructive-auth/SKILL.md) +- **Permissions & RLS:** [`constructive-security`](../constructive-security/SKILL.md) +- **Multi-tenant entities:** [`constructive-entities`](../constructive-entities/SKILL.md) diff --git a/.agents/skills/constructive-security/SKILL.md b/.agents/skills/constructive-security/SKILL.md index 4f8bb79..ab4ccbc 100644 --- a/.agents/skills/constructive-security/SKILL.md +++ b/.agents/skills/constructive-security/SKILL.md @@ -80,34 +80,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 3ff0b16..34f93dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,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" + } +}