Skip to content

Commit 39db490

Browse files
authored
Merge pull request #25 from constructive-io/feat/blocks
fix(nextjs-template): flow-aware degradation + api-<sub> endpoint + scoped tsconfig
2 parents eb01ab1 + 117a108 commit 39db490

85 files changed

Lines changed: 917 additions & 8993 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

graphql/provision/provision.config.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,43 @@ export interface ProvisionRelation {
2626
};
2727
}
2828

29-
export type ModuleSpec = string | [string, { scope?: string }];
29+
/**
30+
* A provision module. Scoped modules MUST use tuple form
31+
* (['permissions_module', { scope: 'app' }]) — the live provision proc rejects
32+
* the colon-string form ('permissions_module:app') with a PROVISION-001 hard-fail.
33+
*/
34+
export type ModuleScope = { scope: 'app' | 'org' };
35+
export type ProvisionModule = string | [string, ModuleScope];
36+
37+
/**
38+
* BASE tier default — the `auth:email` preset. Mirrors
39+
* references/flows.json → email-password.backend.modules verbatim.
40+
*
41+
* NO org / memberships{org} / hierarchy / invites modules: a base scaffold
42+
* ships no org/b2b code. B2B apps layer the org modules on top (see docs/B2B.md).
43+
*/
44+
export const AUTH_EMAIL_MODULES: ProvisionModule[] = [
45+
'users_module',
46+
'membership_types_module',
47+
['permissions_module', { scope: 'app' }],
48+
['limits_module', { scope: 'app' }],
49+
['levels_module', { scope: 'app' }],
50+
['memberships_module', { scope: 'app' }],
51+
'sessions_module',
52+
'user_state_module',
53+
'user_credentials_module',
54+
'config_secrets_module',
55+
'emails_module',
56+
'rls_module',
57+
'user_auth_module'
58+
];
3059

3160
export interface ProvisionConfig {
3261
platformAuth: string;
3362
platformApi: string;
3463
database: {
3564
name: string;
36-
modules: ModuleSpec[];
65+
modules: ProvisionModule[];
3766
bootstrapUser: boolean;
3867
};
3968
auth: {
@@ -96,7 +125,10 @@ export function defineProvisionConfig(config: ProvisionConfig): ProvisionConfig
96125
* platformApi: 'http://api.localhost:3000/graphql',
97126
* database: {
98127
* name: 'taskmanager',
99-
* modules: ['all'],
128+
* // BASE tier: the auth:email preset. Import AUTH_EMAIL_MODULES (above)
129+
* // and spread it, adding only what your app needs. NEVER use ['all'] or
130+
* // colon-string scoped modules — the provision proc rejects both.
131+
* modules: AUTH_EMAIL_MODULES,
100132
* bootstrapUser: true,
101133
* },
102134
* auth: {
@@ -158,7 +190,10 @@ export default defineProvisionConfig({
158190
platformApi: 'http://api.localhost:3000/graphql',
159191
database: {
160192
name: 'REPLACE_ME',
161-
modules: ['all'] as ModuleSpec[],
193+
// BASE tier default: the auth:email preset (see AUTH_EMAIL_MODULES above).
194+
// Do NOT use ['all'] — the live provision proc rejects it. Layer org
195+
// modules on top only for a b2b app (see docs/B2B.md).
196+
modules: AUTH_EMAIL_MODULES,
162197
bootstrapUser: true,
163198
},
164199
auth: {

graphql/provision/provision.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@ async function main(): Promise<void> {
226226
ownerId: userId,
227227
subdomain: config.database.name,
228228
domain: 'localhost',
229-
modules: config.database.modules as any,
229+
// The generated SDK types `modules` as string[]; the server accepts the
230+
// JSONB tuple form (['permissions_module', { scope: 'app' }]) at runtime.
231+
modules: config.database.modules as unknown as string[],
230232
bootstrapUser: config.database.bootstrapUser,
231233
},
232234
select: { id: true, databaseId: true, databaseName: true, status: true },
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# B2B / Organizations — opt-in
2+
3+
This template ships as a **BASE tier** app: the `auth:email` module set only.
4+
Out of the box it has authentication (sign in / up / out, password reset, email
5+
verification), an account/profile surface, and a place to build your own app
6+
data. It ships **no** organization, members, roles, or invite code — so a base
7+
app builds clean with zero org imports.
8+
9+
Multi-tenant / team features (organizations, members, roles, org settings) are a
10+
deliberate **opt-in**. You add them in two coordinated steps; you do **not**
11+
hand-write the org UI — the registry org blocks already implement it.
12+
13+
## 1. Provision the org modules
14+
15+
The base default module set lives in `packages/provision/src/modules.ts`
16+
(`AUTH_EMAIL_MODULES`). To go b2b, extend it with `ORG_MODULES` (also exported
17+
from that file) when creating the database:
18+
19+
```ts
20+
// packages/provision/src/create-db.ts
21+
import { asModules, AUTH_EMAIL_MODULES, ORG_MODULES } from './modules.js';
22+
23+
const APP_MODULES = [...AUTH_EMAIL_MODULES, ...ORG_MODULES];
24+
// ...
25+
modules: asModules(APP_MODULES),
26+
```
27+
28+
`ORG_MODULES` mirrors the `organization` flow's backend module set from the
29+
flow catalog (`references/flows.json`): the org-scoped `permissions`, `limits`,
30+
`levels`, `memberships`, `profiles`, `hierarchy` modules plus app- and
31+
org-scoped `invites`. All scoped modules are tuple form
32+
(`['memberships_module', { scope: 'org' }]`) — the colon-string form
33+
(`'memberships_module:org'`) is rejected by the provision proc.
34+
35+
After provisioning, re-run codegen (`pnpm codegen`) so the generated admin SDK
36+
(`@sdk/admin`) gains the org / members / invites query + mutation hooks that the
37+
org blocks consume.
38+
39+
## 2. Add the registry org blocks
40+
41+
Install the organization blocks from the Constructive blocks registry and mount
42+
them on your org routes — they bring their own data hooks, wired to the
43+
org-scoped admin SDK:
44+
45+
```bash
46+
npx shadcn@latest add org-create-card org-members-list org-roles-editor org-settings-form
47+
```
48+
49+
| Block | Purpose |
50+
| ------------------- | ---------------------------------------- |
51+
| `org-create-card` | Create a new organization |
52+
| `org-members-list` | List / manage members of an organization |
53+
| `org-roles-editor` | Edit member roles & permissions |
54+
| `org-settings-form` | Organization profile & settings |
55+
56+
Then reintroduce the org navigation seam that the base intentionally leaves
57+
minimal:
58+
59+
- `src/lib/navigation/sidebar-config.ts` — add an `Organizations` nav entry and
60+
an org-level nav group.
61+
- `src/lib/navigation/use-entity-params.ts` — reintroduce the `orgId` path param
62+
(and an org switcher) so `/orgs/[orgId]/*` routes resolve.
63+
- `src/app-routes.ts` — add the org-scoped routes; the
64+
`requiredPermission: 'app-admin'` gate in `RouteGuard` is already present for
65+
app-admin surfaces to reuse.
66+
67+
That's the whole opt-in: provision the modules, regenerate the SDK, drop in the
68+
blocks, and wire the routes. No org UI is hand-written in this template.
69+
70+
## 3. Prerequisite: the org-create RLS permission bit
71+
72+
Creating an organization is a `createUser` with **`type = 2`** (an org is modelled
73+
as an entity-typed user row). The `users` table is RLS-protected, and the INSERT
74+
policy for entity-type rows requires the **acting** user to hold the
75+
**`create_entity`** app permission. Without it the create fails with:
76+
77+
```
78+
new row violates row-level security policy for table "users"
79+
```
80+
81+
`create_entity` is the 5th app-permission bit defined by `initialize_permissions`
82+
(app scope) — i.e. bit value `0x10000` (`1 << 16`) in the 64-bit app permission
83+
mask. The actor must have it set on their **app membership** row before they call
84+
the org-create mutation (the `org-create-card` block).
85+
86+
How this template grants it: `packages/provision/src/create-db.ts` already
87+
elevates the bootstrap admin to full permissions right after provisioning — it
88+
resolves **this tenant's** `memberships_public` schema via the metaschema
89+
(scoped by `database_id`, not a floating `LIKE`) and runs:
90+
91+
```sql
92+
UPDATE "<tenant>_memberships_public".app_memberships
93+
SET is_admin = true, is_owner = true,
94+
permissions = (64 one-bits)::bit(64) -- includes the create_entity bit
95+
WHERE actor_id = $1;
96+
```
97+
98+
So the admin user that `create-db` registers can create orgs out of the box. For
99+
**non-admin** users who must create orgs, grant just the `create_entity` bit on
100+
their app membership (set bit `0x10000`, or via the admin SDK
101+
`appMembership.update`) — do not blanket-grant `is_admin`.
102+
103+
### Note: `@constructive-io/node` and `*.localhost` ("fetch failed")
104+
105+
`create-db.ts` registers the org/admin user against the per-tenant auth host
106+
`auth-<sub>.localhost` using `@constructive-io/node`'s `auth.createClient`. The
107+
convenience `{ endpoint }` form builds a `FetchAdapter` that calls
108+
`globalThis.fetch` directly. On many Linux/CI hosts Node cannot resolve
109+
`*.localhost` (ENOTFOUND → **"fetch failed"**), and undici also drops a manual
110+
`Host` header, breaking subdomain routing. (macOS resolves `*.localhost` to
111+
127.0.0.1, so it often "just works" locally — but CI will not.)
112+
113+
Workaround — pass a `NodeHttpAdapter` (exported by `@constructive-io/node`)
114+
instead of `endpoint`; it rewrites the hostname to `localhost` and injects the
115+
original host as the `Host` header:
116+
117+
```ts
118+
import { auth, NodeHttpAdapter } from '@constructive-io/node';
119+
120+
const dbAuthClient = auth.createClient({
121+
adapter: new NodeHttpAdapter(`http://auth-${databaseName}.localhost:3000/graphql`),
122+
});
123+
```
124+
125+
The package's own raw-HTTP path (`helpers.rawExecute`) already routes through
126+
`@constructive-io/fetch`'s `createFetch()`, which applies the same rewrite — so
127+
prefer that (or `NodeHttpAdapter`) for any `*.localhost` call that errors with
128+
"fetch failed" in CI.

nextjs/constructive-app/graphql-codegen.config.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,40 @@ if (!DB_NAME) {
4444
}
4545

4646
// Codegen connects via subdomain-based virtual hosts. Endpoints use the
47-
// per-DB subdomain pattern: admin-{db}.localhost, auth-{db}.localhost,
48-
// app-{db}.localhost. The Host header controls server-side API routing.
47+
// per-DB subdomain pattern: admin-{db}.localhost, auth-{db}.localhost, and the
48+
// app DATA subdomain (api-{db}.localhost by default). The HTTP Host header — not
49+
// the URL — drives server-side API routing, so a URL override alone still 404s;
50+
// the Host must match the routed subdomain.
51+
//
52+
// The {db} segment is the database name with hyphens; PostGraphile maps it back
53+
// to the physical db name by converting hyphens to underscores. Discover the
54+
// exact per-DB domains from services_public.domains if the defaults don't match.
55+
//
56+
// Host overrides (used by both endpoint + Host header so they stay in sync):
57+
// - CODEGEN_ADMIN_HOST (default: admin-{db}.localhost:3000)
58+
// - CODEGEN_AUTH_HOST (default: auth-{db}.localhost:3000)
59+
// - CODEGEN_APP_HOST (default: api-{db}.localhost:3000) ← app business data
60+
const ADMIN_HOST = process.env.CODEGEN_ADMIN_HOST ?? `admin-${DB_NAME}.localhost:3000`;
61+
const AUTH_HOST = process.env.CODEGEN_AUTH_HOST ?? `auth-${DB_NAME}.localhost:3000`;
62+
const APP_HOST = process.env.CODEGEN_APP_HOST ?? `api-${DB_NAME}.localhost:3000`;
63+
4964
const config: Record<string, GraphQLSDKConfigTarget> = {
5065
admin: {
5166
reactQuery: true,
52-
endpoint: process.env.CODEGEN_ADMIN_ENDPOINT ?? `http://admin-${DB_NAME}.localhost:3000/graphql`,
53-
headers: { Host: `admin-${DB_NAME}.localhost:3000` },
67+
endpoint: process.env.CODEGEN_ADMIN_ENDPOINT ?? `http://${ADMIN_HOST}/graphql`,
68+
headers: { Host: ADMIN_HOST },
5469
output: './src/graphql/sdk/admin',
5570
},
5671
auth: {
5772
reactQuery: true,
58-
endpoint: process.env.CODEGEN_AUTH_ENDPOINT ?? `http://auth-${DB_NAME}.localhost:3000/graphql`,
59-
headers: { Host: `auth-${DB_NAME}.localhost:3000` },
73+
endpoint: process.env.CODEGEN_AUTH_ENDPOINT ?? `http://${AUTH_HOST}/graphql`,
74+
headers: { Host: AUTH_HOST },
6075
output: './src/graphql/sdk/auth',
6176
},
6277
app: {
6378
reactQuery: true,
64-
endpoint: process.env.CODEGEN_APP_ENDPOINT ?? `http://api-${DB_NAME}.localhost:3000/graphql`,
65-
headers: { Host: `api-${DB_NAME}.localhost:3000` },
79+
endpoint: process.env.CODEGEN_APP_ENDPOINT ?? `http://${APP_HOST}/graphql`,
80+
headers: { Host: APP_HOST },
6681
output: './src/graphql/sdk/app',
6782
},
6883
};

nextjs/constructive-app/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
6-
"dev": "next dev --turbopack --port 3011",
6+
"dev": "next dev --turbopack --port ${PORT:-3011}",
77
"build": "next build",
8-
"start": "next start",
8+
"start": "next start --port ${PORT:-3011}",
99
"lint": "eslint .",
1010
"lint:types": "tsc --noEmit",
1111
"deps": "pnpm up -r -i -L",

nextjs/constructive-app/packages/provision/src/config.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
1-
/** config.ts — Endpoint and credential configuration */
1+
/**
2+
* config.ts — Centralized configuration for constructive-app provisioning
3+
*
4+
* The Constructive hub is Host-routed (one cnc server on :3000, virtual hosts):
5+
* auth.localhost:3000 → Global auth API (sign up / sign in for create-db)
6+
* api.localhost:3000 → Metaschema READS (apis/schemas/tables/databases)
7+
* + createApiSchema / createDatabase (schema-builder)
8+
* modules.localhost:3000 → Provisioning MUTATIONS: createDatabaseProvisionModule,
9+
* createBlueprint, constructBlueprint,
10+
* createSecureTableProvision (the metaschema_modules API)
11+
* auth-{dbName}.localhost:3000 → Per-tenant auth (tokens valid for this DB's endpoints)
12+
* api-{dbName}.localhost:3000 → Per-tenant app DATA (the codegen `app` SDK host)
13+
* admin-{dbName}.localhost:3000 → Per-tenant admin API (orgs, members, permissions)
14+
*
15+
* CRITICAL: the provisioning mutations live on `modules.localhost`, NOT `api.localhost`.
16+
* Pointing them at api.localhost fails with "Unknown type ...Input" because those
17+
* input types are only registered on the modules host. See helpers.createModulesClient.
18+
*
19+
* Override via API_ENDPOINT / MODULES_ENDPOINT / AUTH_ENDPOINT env vars.
20+
*/
221

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

928
export const config = {
29+
/** Metaschema READ endpoint — apis/schemas/tables/databases + createApiSchema/createDatabase */
1030
apiEndpoint: process.env.API_ENDPOINT || 'http://api.localhost:3000/graphql',
31+
32+
/** Auth API endpoint — global metaschema sign up (used by create-db) */
1133
authEndpoint: process.env.AUTH_ENDPOINT || 'http://auth.localhost:3000/graphql',
12-
modulesEndpoint: process.env.MODULES_ENDPOINT || 'http://modules.localhost:3000/graphql',
34+
35+
/**
36+
* Provisioning MUTATION endpoint — createDatabaseProvisionModule, createBlueprint,
37+
* constructBlueprint, createSecureTableProvision (the metaschema_modules API).
38+
* These types are ONLY on the modules host; routing them to apiEndpoint fails with
39+
* 'Unknown type ...Input'. MODULES_ENDPOINT is the canonical override (shipped in
40+
* .env.example); PROVISION_MODULES_ENDPOINT / PG_PROVISION_MODULES_ENDPOINT are also
41+
* accepted for parity with the PG-prefixed env vars used elsewhere.
42+
*/
43+
modulesEndpoint:
44+
process.env.MODULES_ENDPOINT ||
45+
process.env.PROVISION_MODULES_ENDPOINT ||
46+
process.env.PG_PROVISION_MODULES_ENDPOINT ||
47+
'http://modules.localhost:3000/graphql',
1348

1449
get dbAuthEndpoint(): string {
1550
return process.env.DB_AUTH_ENDPOINT || `http://auth-${this.databaseName}.localhost:3000/graphql`;
1651
},
52+
53+
/** App DATA endpoint — per-tenant app business data (the `api-{dbName}` host codegen targets) */
1754
get appEndpoint(): string {
1855
return process.env.APP_ENDPOINT || `http://api-${this.databaseName}.localhost:3000/graphql`;
1956
},

0 commit comments

Comments
 (0)