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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions graphql/provision/provision.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,43 @@ export interface ProvisionRelation {
};
}

export type ModuleSpec = string | [string, { scope?: string }];
/**
* A provision module. Scoped modules MUST use tuple form
* (['permissions_module', { scope: 'app' }]) — the live provision proc rejects
* the colon-string form ('permissions_module:app') with a PROVISION-001 hard-fail.
*/
export type ModuleScope = { scope: 'app' | 'org' };
export type ProvisionModule = string | [string, ModuleScope];

/**
* BASE tier default — the `auth:email` preset. Mirrors
* references/flows.json → email-password.backend.modules verbatim.
*
* NO org / memberships{org} / hierarchy / invites modules: a base scaffold
* ships no org/b2b code. B2B apps layer the org modules on top (see docs/B2B.md).
*/
export const AUTH_EMAIL_MODULES: ProvisionModule[] = [
'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'
];

export interface ProvisionConfig {
platformAuth: string;
platformApi: string;
database: {
name: string;
modules: ModuleSpec[];
modules: ProvisionModule[];
bootstrapUser: boolean;
};
auth: {
Expand Down Expand Up @@ -96,7 +125,10 @@ export function defineProvisionConfig(config: ProvisionConfig): ProvisionConfig
* platformApi: 'http://api.localhost:3000/graphql',
* database: {
* name: 'taskmanager',
* modules: ['all'],
* // BASE tier: the auth:email preset. Import AUTH_EMAIL_MODULES (above)
* // and spread it, adding only what your app needs. NEVER use ['all'] or
* // colon-string scoped modules — the provision proc rejects both.
* modules: AUTH_EMAIL_MODULES,
* bootstrapUser: true,
* },
* auth: {
Expand Down Expand Up @@ -158,7 +190,10 @@ export default defineProvisionConfig({
platformApi: 'http://api.localhost:3000/graphql',
database: {
name: 'REPLACE_ME',
modules: ['all'] as ModuleSpec[],
// BASE tier default: the auth:email preset (see AUTH_EMAIL_MODULES above).
// Do NOT use ['all'] — the live provision proc rejects it. Layer org
// modules on top only for a b2b app (see docs/B2B.md).
modules: AUTH_EMAIL_MODULES,
bootstrapUser: true,
},
auth: {
Expand Down
4 changes: 3 additions & 1 deletion graphql/provision/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ async function main(): Promise<void> {
ownerId: userId,
subdomain: config.database.name,
domain: 'localhost',
modules: config.database.modules as any,
// The generated SDK types `modules` as string[]; the server accepts the
// JSONB tuple form (['permissions_module', { scope: 'app' }]) at runtime.
modules: config.database.modules as unknown as string[],
bootstrapUser: config.database.bootstrapUser,
},
select: { id: true, databaseId: true, databaseName: true, status: true },
Expand Down
128 changes: 128 additions & 0 deletions nextjs/constructive-app/docs/B2B.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# B2B / Organizations — opt-in

This template ships as a **BASE tier** app: the `auth:email` module set only.
Out of the box it has authentication (sign in / up / out, password reset, email
verification), an account/profile surface, and a place to build your own app
data. It ships **no** organization, members, roles, or invite code — so a base
app builds clean with zero org imports.

Multi-tenant / team features (organizations, members, roles, org settings) are a
deliberate **opt-in**. You add them in two coordinated steps; you do **not**
hand-write the org UI — the registry org blocks already implement it.

## 1. Provision the org modules

The base default module set lives in `packages/provision/src/modules.ts`
(`AUTH_EMAIL_MODULES`). To go b2b, extend it with `ORG_MODULES` (also exported
from that file) when creating the database:

```ts
// packages/provision/src/create-db.ts
import { asModules, AUTH_EMAIL_MODULES, ORG_MODULES } from './modules.js';

const APP_MODULES = [...AUTH_EMAIL_MODULES, ...ORG_MODULES];
// ...
modules: asModules(APP_MODULES),
```

`ORG_MODULES` mirrors the `organization` flow's backend module set from the
flow catalog (`references/flows.json`): the org-scoped `permissions`, `limits`,
`levels`, `memberships`, `profiles`, `hierarchy` modules plus app- and
org-scoped `invites`. All scoped modules are tuple form
(`['memberships_module', { scope: 'org' }]`) — the colon-string form
(`'memberships_module:org'`) is rejected by the provision proc.

After provisioning, re-run codegen (`pnpm codegen`) so the generated admin SDK
(`@sdk/admin`) gains the org / members / invites query + mutation hooks that the
org blocks consume.

## 2. Add the registry org blocks

Install the organization blocks from the Constructive blocks registry and mount
them on your org routes — they bring their own data hooks, wired to the
org-scoped admin SDK:

```bash
npx shadcn@latest add org-create-card org-members-list org-roles-editor org-settings-form
```

| Block | Purpose |
| ------------------- | ---------------------------------------- |
| `org-create-card` | Create a new organization |
| `org-members-list` | List / manage members of an organization |
| `org-roles-editor` | Edit member roles & permissions |
| `org-settings-form` | Organization profile & settings |

Then reintroduce the org navigation seam that the base intentionally leaves
minimal:

- `src/lib/navigation/sidebar-config.ts` — add an `Organizations` nav entry and
an org-level nav group.
- `src/lib/navigation/use-entity-params.ts` — reintroduce the `orgId` path param
(and an org switcher) so `/orgs/[orgId]/*` routes resolve.
- `src/app-routes.ts` — add the org-scoped routes; the
`requiredPermission: 'app-admin'` gate in `RouteGuard` is already present for
app-admin surfaces to reuse.

That's the whole opt-in: provision the modules, regenerate the SDK, drop in the
blocks, and wire the routes. No org UI is hand-written in this template.

## 3. Prerequisite: the org-create RLS permission bit

Creating an organization is a `createUser` with **`type = 2`** (an org is modelled
as an entity-typed user row). The `users` table is RLS-protected, and the INSERT
policy for entity-type rows requires the **acting** user to hold the
**`create_entity`** app permission. Without it the create fails with:

```
new row violates row-level security policy for table "users"
```

`create_entity` is the 5th app-permission bit defined by `initialize_permissions`
(app scope) — i.e. bit value `0x10000` (`1 << 16`) in the 64-bit app permission
mask. The actor must have it set on their **app membership** row before they call
the org-create mutation (the `org-create-card` block).

How this template grants it: `packages/provision/src/create-db.ts` already
elevates the bootstrap admin to full permissions right after provisioning — it
resolves **this tenant's** `memberships_public` schema via the metaschema
(scoped by `database_id`, not a floating `LIKE`) and runs:

```sql
UPDATE "<tenant>_memberships_public".app_memberships
SET is_admin = true, is_owner = true,
permissions = (64 one-bits)::bit(64) -- includes the create_entity bit
WHERE actor_id = $1;
```

So the admin user that `create-db` registers can create orgs out of the box. For
**non-admin** users who must create orgs, grant just the `create_entity` bit on
their app membership (set bit `0x10000`, or via the admin SDK
`appMembership.update`) — do not blanket-grant `is_admin`.

### Note: `@constructive-io/node` and `*.localhost` ("fetch failed")

`create-db.ts` registers the org/admin user against the per-tenant auth host
`auth-<sub>.localhost` using `@constructive-io/node`'s `auth.createClient`. The
convenience `{ endpoint }` form builds a `FetchAdapter` that calls
`globalThis.fetch` directly. On many Linux/CI hosts Node cannot resolve
`*.localhost` (ENOTFOUND → **"fetch failed"**), and undici also drops a manual
`Host` header, breaking subdomain routing. (macOS resolves `*.localhost` to
127.0.0.1, so it often "just works" locally — but CI will not.)

Workaround — pass a `NodeHttpAdapter` (exported by `@constructive-io/node`)
instead of `endpoint`; it rewrites the hostname to `localhost` and injects the
original host as the `Host` header:

```ts
import { auth, NodeHttpAdapter } from '@constructive-io/node';

const dbAuthClient = auth.createClient({
adapter: new NodeHttpAdapter(`http://auth-${databaseName}.localhost:3000/graphql`),
});
```

The package's own raw-HTTP path (`helpers.rawExecute`) already routes through
`@constructive-io/fetch`'s `createFetch()`, which applies the same rewrite — so
prefer that (or `NodeHttpAdapter`) for any `*.localhost` call that errors with
"fetch failed" in CI.
31 changes: 23 additions & 8 deletions nextjs/constructive-app/graphql-codegen.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,40 @@ if (!DB_NAME) {
}

// Codegen connects via subdomain-based virtual hosts. Endpoints use the
// per-DB subdomain pattern: admin-{db}.localhost, auth-{db}.localhost,
// app-{db}.localhost. The Host header controls server-side API routing.
// per-DB subdomain pattern: admin-{db}.localhost, auth-{db}.localhost, and the
// app DATA subdomain (api-{db}.localhost by default). The HTTP Host header — not
// the URL — drives server-side API routing, so a URL override alone still 404s;
// the Host must match the routed subdomain.
//
// The {db} segment is the database name with hyphens; PostGraphile maps it back
// to the physical db name by converting hyphens to underscores. Discover the
// exact per-DB domains from services_public.domains if the defaults don't match.
//
// Host overrides (used by both endpoint + Host header so they stay in sync):
// - CODEGEN_ADMIN_HOST (default: admin-{db}.localhost:3000)
// - CODEGEN_AUTH_HOST (default: auth-{db}.localhost:3000)
// - CODEGEN_APP_HOST (default: api-{db}.localhost:3000) ← app business data
const ADMIN_HOST = process.env.CODEGEN_ADMIN_HOST ?? `admin-${DB_NAME}.localhost:3000`;
const AUTH_HOST = process.env.CODEGEN_AUTH_HOST ?? `auth-${DB_NAME}.localhost:3000`;
const APP_HOST = process.env.CODEGEN_APP_HOST ?? `api-${DB_NAME}.localhost:3000`;

const config: Record<string, GraphQLSDKConfigTarget> = {
admin: {
reactQuery: true,
endpoint: process.env.CODEGEN_ADMIN_ENDPOINT ?? `http://admin-${DB_NAME}.localhost:3000/graphql`,
headers: { Host: `admin-${DB_NAME}.localhost:3000` },
endpoint: process.env.CODEGEN_ADMIN_ENDPOINT ?? `http://${ADMIN_HOST}/graphql`,
headers: { Host: ADMIN_HOST },
output: './src/graphql/sdk/admin',
},
auth: {
reactQuery: true,
endpoint: process.env.CODEGEN_AUTH_ENDPOINT ?? `http://auth-${DB_NAME}.localhost:3000/graphql`,
headers: { Host: `auth-${DB_NAME}.localhost:3000` },
endpoint: process.env.CODEGEN_AUTH_ENDPOINT ?? `http://${AUTH_HOST}/graphql`,
headers: { Host: AUTH_HOST },
output: './src/graphql/sdk/auth',
},
app: {
reactQuery: true,
endpoint: process.env.CODEGEN_APP_ENDPOINT ?? `http://api-${DB_NAME}.localhost:3000/graphql`,
headers: { Host: `api-${DB_NAME}.localhost:3000` },
endpoint: process.env.CODEGEN_APP_ENDPOINT ?? `http://${APP_HOST}/graphql`,
headers: { Host: APP_HOST },
output: './src/graphql/sdk/app',
},
};
Expand Down
4 changes: 2 additions & 2 deletions nextjs/constructive-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3011",
"dev": "next dev --turbopack --port ${PORT:-3011}",
"build": "next build",
"start": "next start",
"start": "next start --port ${PORT:-3011}",
"lint": "eslint .",
"lint:types": "tsc --noEmit",
"deps": "pnpm up -r -i -L",
Expand Down
41 changes: 39 additions & 2 deletions nextjs/constructive-app/packages/provision/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
/** config.ts — Endpoint and credential configuration */
/**
* config.ts — Centralized configuration for constructive-app provisioning
*
* The Constructive hub is Host-routed (one cnc server on :3000, virtual hosts):
* auth.localhost:3000 → Global auth API (sign up / sign in for create-db)
* api.localhost:3000 → Metaschema READS (apis/schemas/tables/databases)
* + createApiSchema / createDatabase (schema-builder)
* modules.localhost:3000 → Provisioning MUTATIONS: createDatabaseProvisionModule,
* createBlueprint, constructBlueprint,
* createSecureTableProvision (the metaschema_modules API)
* auth-{dbName}.localhost:3000 → Per-tenant auth (tokens valid for this DB's endpoints)
* api-{dbName}.localhost:3000 → Per-tenant app DATA (the codegen `app` SDK host)
* admin-{dbName}.localhost:3000 → Per-tenant admin API (orgs, members, permissions)
*
* CRITICAL: the provisioning mutations live on `modules.localhost`, NOT `api.localhost`.
* Pointing them at api.localhost fails with "Unknown type ...Input" because those
* input types are only registered on the modules host. See helpers.createModulesClient.
*
* Override via API_ENDPOINT / MODULES_ENDPOINT / AUTH_ENDPOINT env vars.
*/

import dotenv from 'dotenv';
import * as path from 'path';
Expand All @@ -7,13 +26,31 @@ import * as path from 'path';
dotenv.config({ path: path.resolve(process.cwd(), '../../.env') });

export const config = {
/** Metaschema READ endpoint — apis/schemas/tables/databases + createApiSchema/createDatabase */
apiEndpoint: process.env.API_ENDPOINT || 'http://api.localhost:3000/graphql',

/** Auth API endpoint — global metaschema sign up (used by create-db) */
authEndpoint: process.env.AUTH_ENDPOINT || 'http://auth.localhost:3000/graphql',
modulesEndpoint: process.env.MODULES_ENDPOINT || 'http://modules.localhost:3000/graphql',

/**
* Provisioning MUTATION endpoint — createDatabaseProvisionModule, createBlueprint,
* constructBlueprint, createSecureTableProvision (the metaschema_modules API).
* These types are ONLY on the modules host; routing them to apiEndpoint fails with
* 'Unknown type ...Input'. MODULES_ENDPOINT is the canonical override (shipped in
* .env.example); PROVISION_MODULES_ENDPOINT / PG_PROVISION_MODULES_ENDPOINT are also
* accepted for parity with the PG-prefixed env vars used elsewhere.
*/
modulesEndpoint:
process.env.MODULES_ENDPOINT ||
process.env.PROVISION_MODULES_ENDPOINT ||
process.env.PG_PROVISION_MODULES_ENDPOINT ||
'http://modules.localhost:3000/graphql',

get dbAuthEndpoint(): string {
return process.env.DB_AUTH_ENDPOINT || `http://auth-${this.databaseName}.localhost:3000/graphql`;
},

/** App DATA endpoint — per-tenant app business data (the `api-{dbName}` host codegen targets) */
get appEndpoint(): string {
return process.env.APP_ENDPOINT || `http://api-${this.databaseName}.localhost:3000/graphql`;
},
Expand Down
Loading