create-smoodly-app 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,8 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
30
30
  so an app that keeps its own tables in Supabase gives them a project of
31
31
  their own, or takes Smoodly Cloud for content (DESIGN.md §1).
32
32
  4. The first editor: pick an existing Auth user or create one (email +
33
- password). The `editors` row is written with role `owner`.
33
+ password). The `members` row is written with role `owner`, the email
34
+ beside the id.
34
35
  If `@` does not appear when you type it (macOS, `@` on Option+2 with the
35
36
  terminal's "Option as Meta" setting on), paste the address or pass
36
37
  `--editor-email`.
@@ -174,7 +175,7 @@ scaffolded in hosted mode against the repo stack and `next build` only.
174
175
  (done 2026-09-05, throwaway project, `sb_secret_` + `sb_publishable_`):
175
176
  `db push` of the template migration printed no NOTICE, the CLI listed and
176
177
  created the first editor through the GoTrue admin API with the secret key
177
- as the sole `apikey`, the `editors` row landed with role `owner`, `next
178
+ as the sole `apikey`, the `members` row landed with role `owner`, `next
178
179
  build` passed, `/admin` served the shell, and a password sign-in with the
179
180
  publishable key returned a session. Caveat: the link step ran by hand with
180
181
  an explicit `--project-ref` (see the next-but-one item); the CLI's own
package/dist/index.js CHANGED
@@ -82,7 +82,7 @@ async function ensureEditor(client, ask) {
82
82
  const existing = await client.listUsers();
83
83
  const choice = await ask(existing);
84
84
  const user = choice.kind === "pick" ? choice.user : await client.createUser(choice.email, choice.password);
85
- await client.upsertEditor(user.id);
85
+ await client.upsertEditor(user.id, user.email);
86
86
  return user;
87
87
  }
88
88
  function clientFor(keys) {
@@ -106,9 +106,9 @@ function supabaseEditorClient(keys) {
106
106
  if (res.error) throw fail("creating the Auth user", res.error.message);
107
107
  return { id: res.data.user.id, email: res.data.user.email ?? null };
108
108
  },
109
- async upsertEditor(userId) {
110
- const res = await db.from("editors").upsert({ user_id: userId, role: "owner" }, { onConflict: "space_id,user_id" });
111
- if (res.error) throw fail("inserting the editors row", res.error.message);
109
+ async upsertEditor(userId, email) {
110
+ const res = await db.from("members").upsert({ user_id: userId, email, role: "owner" }, { onConflict: "space_id,user_id" });
111
+ if (res.error) throw fail("inserting the members row", res.error.message);
112
112
  }
113
113
  };
114
114
  }
@@ -503,7 +503,7 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
503
503
  const editorLines = [
504
504
  "Create the first editor: add a user in Supabase Studio (Authentication \u2192 Users), then in the SQL editor:",
505
505
  "",
506
- " insert into editors (user_id, role) values ('<the user id>', 'owner');"
506
+ " insert into members (user_id, email, role) values ('<the user id>', '<the email>', 'owner');"
507
507
  ].join("\n");
508
508
  switch (step) {
509
509
  case "supabaseLocal":
@@ -518,7 +518,7 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
518
518
  return [envLines, "", editorLines].join("\n");
519
519
  case "ensureEditor":
520
520
  return [
521
- "If the editors table is missing, apply the migration first: `supabase db push` (hosted) or `supabase start` (local).",
521
+ "If the members table is missing, apply the migration first: `supabase db push` (hosted) or `supabase start` (local).",
522
522
  "",
523
523
  editorLines
524
524
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.21"
17
+ "smoodly": "0.0.23"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -1,4 +1,4 @@
1
- // Server-only glue: the client, both stores, the site layer and the
1
+ // Server-only glue: the client, the stores, the site layer and the
2
2
  // renderer, constructed lazily so `next build` doesn't need env vars at
3
3
  // import time. The env shape is the package's (SMOODLY_*); whether the
4
4
  // secret is a service-role key or a cloud write key is invisible here.
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabaseMemberStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
@@ -19,7 +19,7 @@ import { collections, config, elements, sections, globals } from "./config";
19
19
  type Renderer = ReturnType<typeof createPageRenderer>;
20
20
  type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
21
21
  type Bound = {
22
- db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
22
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; members: SupabaseMemberStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
23
23
  };
24
24
 
25
25
  let cached: Bound | null = null;
@@ -41,6 +41,8 @@ function bound(): Bound {
41
41
  });
42
42
  // The media library: rows in `assets`, bytes in the public `media` bucket.
43
43
  const assets = new SupabaseAssetStore(db);
44
+ // The admission table, read by the Members page (spec 2026-09-25 §4).
45
+ const members = new SupabaseMemberStore(db);
44
46
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
45
47
  // Shadows the exported `stores` below on purpose: `createSmoodlySite`
46
48
  // takes this local closure, not the export, so it never re-enters `bound()`.
@@ -58,13 +60,13 @@ function bound(): Bound {
58
60
  });
59
61
  // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
60
62
  const q = createSmoodlyQueries({ site, collections, globals });
61
- cached = { db, pages, entries, assets, auth, site, renderer, q };
63
+ cached = { db, pages, entries, assets, members, auth, site, renderer, q };
62
64
  return cached;
63
65
  }
64
66
 
65
67
  export function stores() {
66
- const { pages, entries, assets } = bound();
67
- return { pages, entries, assets };
68
+ const { pages, entries, assets, members } = bound();
69
+ return { pages, entries, assets, members };
68
70
  }
69
71
 
70
72
  /** The server-side client (seed script, one-off admin tasks). */
@@ -227,13 +227,17 @@ alter table "refs" enable row level security;
227
227
  alter table "paths" enable row level security;
228
228
  alter table "assets" enable row level security;
229
229
 
230
- -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
231
- -- for everyone with ONE definition. Self-host fills them by hand or
232
- -- leaves them empty; smoodly.io writes rows with the service role.
233
- -- There is never cloud-only SQL on a content database.
234
- create table if not exists "editors" (
230
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
231
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
232
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
233
+ -- role. There is never cloud-only SQL on a content database.
234
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
235
+ -- email is stored so the Members page needs no Auth lookup — the auth
236
+ -- project is not the content project in the cloud.
237
+ create table if not exists "members" (
235
238
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
236
239
  "user_id" uuid not null,
240
+ "email" text,
237
241
  "role" text not null default 'editor',
238
242
  primary key ("space_id", "user_id")
239
243
  );
@@ -245,14 +249,18 @@ create table if not exists "space_keys" (
245
249
  "revoked_at" timestamptz
246
250
  );
247
251
 
248
- create table if not exists "space_caps" (
252
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
253
+ -- row = self-host: no cap, members managed in /admin. console_url set =
254
+ -- smoodly.io manages the members and /admin links there.
255
+ create table if not exists "spaces" (
249
256
  "space_id" uuid primary key,
250
- "row_cap" integer not null
257
+ "row_cap" integer,
258
+ "console_url" text
251
259
  );
252
260
 
253
- alter table "editors" enable row level security;
261
+ alter table "members" enable row level security;
254
262
  alter table "space_keys" enable row level security;
255
- alter table "space_caps" enable row level security;
263
+ alter table "spaces" enable row level security;
256
264
 
257
265
  -- ── Claim readers. The two lookups run as definer so they can read the
258
266
  -- service-role-only tables from inside a policy; search_path is pinned.
@@ -271,7 +279,7 @@ $$;
271
279
 
272
280
  create or replace function "smoodly_space_row_cap"() returns integer
273
281
  language sql stable security definer set search_path = public as $$
274
- select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
282
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
275
283
  $$;
276
284
 
277
285
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
@@ -288,7 +296,7 @@ begin
288
296
  end;
289
297
  $$;
290
298
 
291
- -- Per table, per space. No space_caps row = no cap (self-host). Trigger
299
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
292
300
  -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
293
301
  -- current space for a keyed insert; smoodly_space_row_cap() reads the
294
302
  -- current space itself, so this never needs the row's space_id as an
@@ -366,8 +374,8 @@ drop trigger if exists "smoodly_a_set_space" on "assets";
366
374
  create trigger "smoodly_a_set_space" before insert on "assets"
367
375
  for each row execute function "smoodly_set_space"();
368
376
 
369
- drop trigger if exists "smoodly_a_set_space" on "editors";
370
- create trigger "smoodly_a_set_space" before insert on "editors"
377
+ drop trigger if exists "smoodly_a_set_space" on "members";
378
+ create trigger "smoodly_a_set_space" before insert on "members"
371
379
  for each row execute function "smoodly_set_space"();
372
380
 
373
381
  drop trigger if exists "smoodly_b_row_cap" on "pages";
@@ -483,11 +491,15 @@ drop policy if exists "smoodly_read" on "assets";
483
491
  create policy "smoodly_read" on "assets" for select
484
492
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
485
493
 
486
- drop policy if exists "smoodly_write" on "editors";
487
- create policy "smoodly_write" on "editors" for all
494
+ drop policy if exists "smoodly_write" on "members";
495
+ create policy "smoodly_write" on "members" for all
488
496
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
489
497
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
490
498
 
499
+ drop policy if exists "smoodly_read" on "spaces";
500
+ create policy "smoodly_read" on "spaces" for select
501
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
502
+
491
503
  insert into storage.buckets ("id", "name", "public", "file_size_limit")
492
504
  values ('media', 'media', true, 104857600)
493
505
  on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.21",
17
+ "smoodly": "0.0.23",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- // Server-only glue: the client, both stores, the site layer and the
1
+ // Server-only glue: the client, the stores, the site layer and the
2
2
  // renderer, constructed lazily so `next build` doesn't need env vars at
3
3
  // import time. The env shape is the package's (SMOODLY_*); whether the
4
4
  // secret is a service-role key or a cloud write key is invisible here.
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabaseMemberStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
@@ -20,7 +20,7 @@ import { collections, config, elements, sections, globals } from "./config";
20
20
  type Renderer = ReturnType<typeof createPageRenderer>;
21
21
  type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
22
22
  type Bound = {
23
- db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
23
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; members: SupabaseMemberStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
24
24
  };
25
25
 
26
26
  let cached: Bound | null = null;
@@ -42,6 +42,8 @@ function bound(): Bound {
42
42
  });
43
43
  // The media library: rows in `assets`, bytes in the public `media` bucket.
44
44
  const assets = new SupabaseAssetStore(db);
45
+ // The admission table, read by the Members page (spec 2026-09-25 §4).
46
+ const members = new SupabaseMemberStore(db);
45
47
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
46
48
  // Shadows the exported `stores` below on purpose: `createSmoodlySite`
47
49
  // takes this local closure, not the export, so it never re-enters `bound()`.
@@ -59,13 +61,13 @@ function bound(): Bound {
59
61
  });
60
62
  // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
61
63
  const q = createSmoodlyQueries({ site, collections, globals });
62
- cached = { db, pages, entries, assets, auth, site, renderer, q };
64
+ cached = { db, pages, entries, assets, members, auth, site, renderer, q };
63
65
  return cached;
64
66
  }
65
67
 
66
68
  export function stores() {
67
- const { pages, entries, assets } = bound();
68
- return { pages, entries, assets };
69
+ const { pages, entries, assets, members } = bound();
70
+ return { pages, entries, assets, members };
69
71
  }
70
72
 
71
73
  /** The server-side client (seed script, one-off admin tasks). */
@@ -227,13 +227,17 @@ alter table "refs" enable row level security;
227
227
  alter table "paths" enable row level security;
228
228
  alter table "assets" enable row level security;
229
229
 
230
- -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
231
- -- for everyone with ONE definition. Self-host fills them by hand or
232
- -- leaves them empty; smoodly.io writes rows with the service role.
233
- -- There is never cloud-only SQL on a content database.
234
- create table if not exists "editors" (
230
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
231
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
232
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
233
+ -- role. There is never cloud-only SQL on a content database.
234
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
235
+ -- email is stored so the Members page needs no Auth lookup — the auth
236
+ -- project is not the content project in the cloud.
237
+ create table if not exists "members" (
235
238
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
236
239
  "user_id" uuid not null,
240
+ "email" text,
237
241
  "role" text not null default 'editor',
238
242
  primary key ("space_id", "user_id")
239
243
  );
@@ -245,14 +249,18 @@ create table if not exists "space_keys" (
245
249
  "revoked_at" timestamptz
246
250
  );
247
251
 
248
- create table if not exists "space_caps" (
252
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
253
+ -- row = self-host: no cap, members managed in /admin. console_url set =
254
+ -- smoodly.io manages the members and /admin links there.
255
+ create table if not exists "spaces" (
249
256
  "space_id" uuid primary key,
250
- "row_cap" integer not null
257
+ "row_cap" integer,
258
+ "console_url" text
251
259
  );
252
260
 
253
- alter table "editors" enable row level security;
261
+ alter table "members" enable row level security;
254
262
  alter table "space_keys" enable row level security;
255
- alter table "space_caps" enable row level security;
263
+ alter table "spaces" enable row level security;
256
264
 
257
265
  -- ── Claim readers. The two lookups run as definer so they can read the
258
266
  -- service-role-only tables from inside a policy; search_path is pinned.
@@ -271,7 +279,7 @@ $$;
271
279
 
272
280
  create or replace function "smoodly_space_row_cap"() returns integer
273
281
  language sql stable security definer set search_path = public as $$
274
- select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
282
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
275
283
  $$;
276
284
 
277
285
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
@@ -288,7 +296,7 @@ begin
288
296
  end;
289
297
  $$;
290
298
 
291
- -- Per table, per space. No space_caps row = no cap (self-host). Trigger
299
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
292
300
  -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
293
301
  -- current space for a keyed insert; smoodly_space_row_cap() reads the
294
302
  -- current space itself, so this never needs the row's space_id as an
@@ -366,8 +374,8 @@ drop trigger if exists "smoodly_a_set_space" on "assets";
366
374
  create trigger "smoodly_a_set_space" before insert on "assets"
367
375
  for each row execute function "smoodly_set_space"();
368
376
 
369
- drop trigger if exists "smoodly_a_set_space" on "editors";
370
- create trigger "smoodly_a_set_space" before insert on "editors"
377
+ drop trigger if exists "smoodly_a_set_space" on "members";
378
+ create trigger "smoodly_a_set_space" before insert on "members"
371
379
  for each row execute function "smoodly_set_space"();
372
380
 
373
381
  drop trigger if exists "smoodly_b_row_cap" on "pages";
@@ -483,11 +491,15 @@ drop policy if exists "smoodly_read" on "assets";
483
491
  create policy "smoodly_read" on "assets" for select
484
492
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
485
493
 
486
- drop policy if exists "smoodly_write" on "editors";
487
- create policy "smoodly_write" on "editors" for all
494
+ drop policy if exists "smoodly_write" on "members";
495
+ create policy "smoodly_write" on "members" for all
488
496
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
489
497
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
490
498
 
499
+ drop policy if exists "smoodly_read" on "spaces";
500
+ create policy "smoodly_read" on "spaces" for select
501
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
502
+
491
503
  insert into storage.buckets ("id", "name", "public", "file_size_limit")
492
504
  values ('media', 'media', true, 104857600)
493
505
  on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
@@ -27,7 +27,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
27
27
 
28
28
  - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the global **quote** placed from the picker's Global group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
29
29
  - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
30
- - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
30
+ - **Company / About** — a path and a page under it (the breadcrumb), the same body sections, plus people.
31
31
  - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
32
32
  - **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
33
33
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.21"
17
+ "smoodly": "0.0.23"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -1,4 +1,4 @@
1
- // Server-only glue: the client, both stores, the site layer and the
1
+ // Server-only glue: the client, the stores, the site layer and the
2
2
  // renderer, constructed lazily so `next build` doesn't need env vars at
3
3
  // import time. The env shape is the package's (SMOODLY_*); whether the
4
4
  // secret is a service-role key or a cloud write key is invisible here.
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabaseMemberStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
@@ -19,7 +19,7 @@ import { collections, config, elements, sections, globals } from "./config";
19
19
  type Renderer = ReturnType<typeof createPageRenderer>;
20
20
  type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
21
21
  type Bound = {
22
- db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
22
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; members: SupabaseMemberStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
23
23
  };
24
24
 
25
25
  let cached: Bound | null = null;
@@ -41,6 +41,8 @@ function bound(): Bound {
41
41
  });
42
42
  // The media library: rows in `assets`, bytes in the public `media` bucket.
43
43
  const assets = new SupabaseAssetStore(db);
44
+ // The admission table, read by the Members page (spec 2026-09-25 §4).
45
+ const members = new SupabaseMemberStore(db);
44
46
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
45
47
  // Shadows the exported `stores` below on purpose: `createSmoodlySite`
46
48
  // takes this local closure, not the export, so it never re-enters `bound()`.
@@ -58,13 +60,13 @@ function bound(): Bound {
58
60
  });
59
61
  // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
60
62
  const q = createSmoodlyQueries({ site, collections, globals });
61
- cached = { db, pages, entries, assets, auth, site, renderer, q };
63
+ cached = { db, pages, entries, assets, members, auth, site, renderer, q };
62
64
  return cached;
63
65
  }
64
66
 
65
67
  export function stores() {
66
- const { pages, entries, assets } = bound();
67
- return { pages, entries, assets };
68
+ const { pages, entries, assets, members } = bound();
69
+ return { pages, entries, assets, members };
68
70
  }
69
71
 
70
72
  /** The server-side client (seed script, one-off admin tasks). */
@@ -227,13 +227,17 @@ alter table "refs" enable row level security;
227
227
  alter table "paths" enable row level security;
228
228
  alter table "assets" enable row level security;
229
229
 
230
- -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
231
- -- for everyone with ONE definition. Self-host fills them by hand or
232
- -- leaves them empty; smoodly.io writes rows with the service role.
233
- -- There is never cloud-only SQL on a content database.
234
- create table if not exists "editors" (
230
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
231
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
232
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
233
+ -- role. There is never cloud-only SQL on a content database.
234
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
235
+ -- email is stored so the Members page needs no Auth lookup — the auth
236
+ -- project is not the content project in the cloud.
237
+ create table if not exists "members" (
235
238
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
236
239
  "user_id" uuid not null,
240
+ "email" text,
237
241
  "role" text not null default 'editor',
238
242
  primary key ("space_id", "user_id")
239
243
  );
@@ -245,14 +249,18 @@ create table if not exists "space_keys" (
245
249
  "revoked_at" timestamptz
246
250
  );
247
251
 
248
- create table if not exists "space_caps" (
252
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
253
+ -- row = self-host: no cap, members managed in /admin. console_url set =
254
+ -- smoodly.io manages the members and /admin links there.
255
+ create table if not exists "spaces" (
249
256
  "space_id" uuid primary key,
250
- "row_cap" integer not null
257
+ "row_cap" integer,
258
+ "console_url" text
251
259
  );
252
260
 
253
- alter table "editors" enable row level security;
261
+ alter table "members" enable row level security;
254
262
  alter table "space_keys" enable row level security;
255
- alter table "space_caps" enable row level security;
263
+ alter table "spaces" enable row level security;
256
264
 
257
265
  -- ── Claim readers. The two lookups run as definer so they can read the
258
266
  -- service-role-only tables from inside a policy; search_path is pinned.
@@ -271,7 +279,7 @@ $$;
271
279
 
272
280
  create or replace function "smoodly_space_row_cap"() returns integer
273
281
  language sql stable security definer set search_path = public as $$
274
- select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
282
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
275
283
  $$;
276
284
 
277
285
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
@@ -288,7 +296,7 @@ begin
288
296
  end;
289
297
  $$;
290
298
 
291
- -- Per table, per space. No space_caps row = no cap (self-host). Trigger
299
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
292
300
  -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
293
301
  -- current space for a keyed insert; smoodly_space_row_cap() reads the
294
302
  -- current space itself, so this never needs the row's space_id as an
@@ -366,8 +374,8 @@ drop trigger if exists "smoodly_a_set_space" on "assets";
366
374
  create trigger "smoodly_a_set_space" before insert on "assets"
367
375
  for each row execute function "smoodly_set_space"();
368
376
 
369
- drop trigger if exists "smoodly_a_set_space" on "editors";
370
- create trigger "smoodly_a_set_space" before insert on "editors"
377
+ drop trigger if exists "smoodly_a_set_space" on "members";
378
+ create trigger "smoodly_a_set_space" before insert on "members"
371
379
  for each row execute function "smoodly_set_space"();
372
380
 
373
381
  drop trigger if exists "smoodly_b_row_cap" on "pages";
@@ -483,11 +491,15 @@ drop policy if exists "smoodly_read" on "assets";
483
491
  create policy "smoodly_read" on "assets" for select
484
492
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
485
493
 
486
- drop policy if exists "smoodly_write" on "editors";
487
- create policy "smoodly_write" on "editors" for all
494
+ drop policy if exists "smoodly_write" on "members";
495
+ create policy "smoodly_write" on "members" for all
488
496
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
489
497
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
490
498
 
499
+ drop policy if exists "smoodly_read" on "spaces";
500
+ create policy "smoodly_read" on "spaces" for select
501
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
502
+
491
503
  insert into storage.buckets ("id", "name", "public", "file_size_limit")
492
504
  values ('media', 'media', true, 104857600)
493
505
  on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
@@ -34,7 +34,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
34
34
 
35
35
  - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the global **quote** placed from the picker's Global group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
36
36
  - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
37
- - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
37
+ - **Company / About** — a path and a page under it (the breadcrumb), the same body sections, plus people.
38
38
  - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
39
39
  - **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
40
40
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.21",
17
+ "smoodly": "0.0.23",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- // Server-only glue: the client, both stores, the site layer and the
1
+ // Server-only glue: the client, the stores, the site layer and the
2
2
  // renderer, constructed lazily so `next build` doesn't need env vars at
3
3
  // import time. The env shape is the package's (SMOODLY_*); whether the
4
4
  // secret is a service-role key or a cloud write key is invisible here.
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabaseMemberStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
@@ -20,7 +20,7 @@ import { collections, config, elements, sections, globals } from "./config";
20
20
  type Renderer = ReturnType<typeof createPageRenderer>;
21
21
  type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
22
22
  type Bound = {
23
- db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
23
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; members: SupabaseMemberStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
24
24
  };
25
25
 
26
26
  let cached: Bound | null = null;
@@ -42,6 +42,8 @@ function bound(): Bound {
42
42
  });
43
43
  // The media library: rows in `assets`, bytes in the public `media` bucket.
44
44
  const assets = new SupabaseAssetStore(db);
45
+ // The admission table, read by the Members page (spec 2026-09-25 §4).
46
+ const members = new SupabaseMemberStore(db);
45
47
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
46
48
  // Shadows the exported `stores` below on purpose: `createSmoodlySite`
47
49
  // takes this local closure, not the export, so it never re-enters `bound()`.
@@ -59,13 +61,13 @@ function bound(): Bound {
59
61
  });
60
62
  // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
61
63
  const q = createSmoodlyQueries({ site, collections, globals });
62
- cached = { db, pages, entries, assets, auth, site, renderer, q };
64
+ cached = { db, pages, entries, assets, members, auth, site, renderer, q };
63
65
  return cached;
64
66
  }
65
67
 
66
68
  export function stores() {
67
- const { pages, entries, assets } = bound();
68
- return { pages, entries, assets };
69
+ const { pages, entries, assets, members } = bound();
70
+ return { pages, entries, assets, members };
69
71
  }
70
72
 
71
73
  /** The server-side client (seed script, one-off admin tasks). */
@@ -227,13 +227,17 @@ alter table "refs" enable row level security;
227
227
  alter table "paths" enable row level security;
228
228
  alter table "assets" enable row level security;
229
229
 
230
- -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
231
- -- for everyone with ONE definition. Self-host fills them by hand or
232
- -- leaves them empty; smoodly.io writes rows with the service role.
233
- -- There is never cloud-only SQL on a content database.
234
- create table if not exists "editors" (
230
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
231
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
232
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
233
+ -- role. There is never cloud-only SQL on a content database.
234
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
235
+ -- email is stored so the Members page needs no Auth lookup — the auth
236
+ -- project is not the content project in the cloud.
237
+ create table if not exists "members" (
235
238
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
236
239
  "user_id" uuid not null,
240
+ "email" text,
237
241
  "role" text not null default 'editor',
238
242
  primary key ("space_id", "user_id")
239
243
  );
@@ -245,14 +249,18 @@ create table if not exists "space_keys" (
245
249
  "revoked_at" timestamptz
246
250
  );
247
251
 
248
- create table if not exists "space_caps" (
252
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
253
+ -- row = self-host: no cap, members managed in /admin. console_url set =
254
+ -- smoodly.io manages the members and /admin links there.
255
+ create table if not exists "spaces" (
249
256
  "space_id" uuid primary key,
250
- "row_cap" integer not null
257
+ "row_cap" integer,
258
+ "console_url" text
251
259
  );
252
260
 
253
- alter table "editors" enable row level security;
261
+ alter table "members" enable row level security;
254
262
  alter table "space_keys" enable row level security;
255
- alter table "space_caps" enable row level security;
263
+ alter table "spaces" enable row level security;
256
264
 
257
265
  -- ── Claim readers. The two lookups run as definer so they can read the
258
266
  -- service-role-only tables from inside a policy; search_path is pinned.
@@ -271,7 +279,7 @@ $$;
271
279
 
272
280
  create or replace function "smoodly_space_row_cap"() returns integer
273
281
  language sql stable security definer set search_path = public as $$
274
- select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
282
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
275
283
  $$;
276
284
 
277
285
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
@@ -288,7 +296,7 @@ begin
288
296
  end;
289
297
  $$;
290
298
 
291
- -- Per table, per space. No space_caps row = no cap (self-host). Trigger
299
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
292
300
  -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
293
301
  -- current space for a keyed insert; smoodly_space_row_cap() reads the
294
302
  -- current space itself, so this never needs the row's space_id as an
@@ -366,8 +374,8 @@ drop trigger if exists "smoodly_a_set_space" on "assets";
366
374
  create trigger "smoodly_a_set_space" before insert on "assets"
367
375
  for each row execute function "smoodly_set_space"();
368
376
 
369
- drop trigger if exists "smoodly_a_set_space" on "editors";
370
- create trigger "smoodly_a_set_space" before insert on "editors"
377
+ drop trigger if exists "smoodly_a_set_space" on "members";
378
+ create trigger "smoodly_a_set_space" before insert on "members"
371
379
  for each row execute function "smoodly_set_space"();
372
380
 
373
381
  drop trigger if exists "smoodly_b_row_cap" on "pages";
@@ -483,11 +491,15 @@ drop policy if exists "smoodly_read" on "assets";
483
491
  create policy "smoodly_read" on "assets" for select
484
492
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
485
493
 
486
- drop policy if exists "smoodly_write" on "editors";
487
- create policy "smoodly_write" on "editors" for all
494
+ drop policy if exists "smoodly_write" on "members";
495
+ create policy "smoodly_write" on "members" for all
488
496
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
489
497
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
490
498
 
499
+ drop policy if exists "smoodly_read" on "spaces";
500
+ create policy "smoodly_read" on "spaces" for select
501
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
502
+
491
503
  insert into storage.buckets ("id", "name", "public", "file_size_limit")
492
504
  values ('media', 'media', true, 104857600)
493
505
  on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";