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 +3 -2
- package/dist/index.js +6 -6
- package/package.json +1 -1
- package/templates/blank/package.json +1 -1
- package/templates/blank/smoodly/server.ts +8 -6
- package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +27 -15
- package/templates/blank-intl/package.json +1 -1
- package/templates/blank-intl/smoodly/server.ts +8 -6
- package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +27 -15
- package/templates/lorem/README.md +1 -1
- package/templates/lorem/package.json +1 -1
- package/templates/lorem/smoodly/server.ts +8 -6
- package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +27 -15
- package/templates/lorem-intl/README.md +1 -1
- package/templates/lorem-intl/package.json +1 -1
- package/templates/lorem-intl/smoodly/server.ts +8 -6
- package/templates/lorem-intl/supabase/migrations/00000000000000_smoodly.sql +27 -15
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 `
|
|
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 `
|
|
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("
|
|
111
|
-
if (res.error) throw fail("inserting the
|
|
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
|
|
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
|
|
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,4 +1,4 @@
|
|
|
1
|
-
// Server-only glue: the client,
|
|
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
|
|
231
|
-
-- for everyone with ONE definition. Self-host fills them
|
|
232
|
-
-- leaves them empty; smoodly.io writes rows with the service
|
|
233
|
-
-- There is never cloud-only SQL on a content database.
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
"row_cap" integer,
|
|
258
|
+
"console_url" text
|
|
251
259
|
);
|
|
252
260
|
|
|
253
|
-
alter table "
|
|
261
|
+
alter table "members" enable row level security;
|
|
254
262
|
alter table "space_keys" enable row level security;
|
|
255
|
-
alter table "
|
|
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 "
|
|
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
|
|
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 "
|
|
370
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
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 "
|
|
487
|
-
create policy "smoodly_write" on "
|
|
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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Server-only glue: the client,
|
|
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
|
|
231
|
-
-- for everyone with ONE definition. Self-host fills them
|
|
232
|
-
-- leaves them empty; smoodly.io writes rows with the service
|
|
233
|
-
-- There is never cloud-only SQL on a content database.
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
"row_cap" integer,
|
|
258
|
+
"console_url" text
|
|
251
259
|
);
|
|
252
260
|
|
|
253
|
-
alter table "
|
|
261
|
+
alter table "members" enable row level security;
|
|
254
262
|
alter table "space_keys" enable row level security;
|
|
255
|
-
alter table "
|
|
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 "
|
|
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
|
|
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 "
|
|
370
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
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 "
|
|
487
|
-
create policy "smoodly_write" on "
|
|
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
|
|
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Server-only glue: the client,
|
|
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
|
|
231
|
-
-- for everyone with ONE definition. Self-host fills them
|
|
232
|
-
-- leaves them empty; smoodly.io writes rows with the service
|
|
233
|
-
-- There is never cloud-only SQL on a content database.
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
"row_cap" integer,
|
|
258
|
+
"console_url" text
|
|
251
259
|
);
|
|
252
260
|
|
|
253
|
-
alter table "
|
|
261
|
+
alter table "members" enable row level security;
|
|
254
262
|
alter table "space_keys" enable row level security;
|
|
255
|
-
alter table "
|
|
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 "
|
|
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
|
|
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 "
|
|
370
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
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 "
|
|
487
|
-
create policy "smoodly_write" on "
|
|
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
|
|
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Server-only glue: the client,
|
|
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
|
|
231
|
-
-- for everyone with ONE definition. Self-host fills them
|
|
232
|
-
-- leaves them empty; smoodly.io writes rows with the service
|
|
233
|
-
-- There is never cloud-only SQL on a content database.
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
"row_cap" integer,
|
|
258
|
+
"console_url" text
|
|
251
259
|
);
|
|
252
260
|
|
|
253
|
-
alter table "
|
|
261
|
+
alter table "members" enable row level security;
|
|
254
262
|
alter table "space_keys" enable row level security;
|
|
255
|
-
alter table "
|
|
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 "
|
|
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
|
|
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 "
|
|
370
|
-
create trigger "smoodly_a_set_space" before insert on "
|
|
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 "
|
|
487
|
-
create policy "smoodly_write" on "
|
|
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";
|