smoodly 0.0.22 → 0.0.24

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.
Files changed (48) hide show
  1. package/README.md +38 -3
  2. package/dist/admin/auth.d.ts +10 -5
  3. package/dist/admin/auth.js +14 -13
  4. package/dist/admin/client-ops.js +1 -0
  5. package/dist/admin/editor/PageSettings.js +6 -0
  6. package/dist/admin/index.d.ts +3 -1
  7. package/dist/admin/next/index.d.ts +3 -1
  8. package/dist/admin/next/op-handler.d.ts +3 -0
  9. package/dist/admin/next/op-handler.js +1 -0
  10. package/dist/admin/ops-impl.d.ts +3 -0
  11. package/dist/admin/ops-impl.js +78 -1
  12. package/dist/admin/ops.d.ts +21 -0
  13. package/dist/admin/shell/AdminApp.js +28 -2
  14. package/dist/admin/shell/EntryForm.js +17 -0
  15. package/dist/admin/shell/IconRail.d.ts +3 -1
  16. package/dist/admin/shell/IconRail.js +6 -5
  17. package/dist/admin/shell/InviteModal.d.ts +6 -0
  18. package/dist/admin/shell/InviteModal.js +24 -0
  19. package/dist/admin/shell/ListView.d.ts +3 -1
  20. package/dist/admin/shell/ListView.js +2 -2
  21. package/dist/admin/shell/LoginView.js +34 -5
  22. package/dist/admin/shell/MembersPage.d.ts +6 -0
  23. package/dist/admin/shell/MembersPage.js +83 -0
  24. package/dist/admin/shell/PagesList.js +7 -0
  25. package/dist/admin/shell/SetPasswordView.d.ts +7 -0
  26. package/dist/admin/shell/SetPasswordView.js +41 -0
  27. package/dist/admin/shell/SettingsView.d.ts +9 -0
  28. package/dist/admin/shell/SettingsView.js +49 -0
  29. package/dist/admin/shell/live-url.d.ts +9 -0
  30. package/dist/admin/shell/live-url.js +10 -0
  31. package/dist/admin/shell/members-list.d.ts +21 -0
  32. package/dist/admin/shell/members-list.js +21 -0
  33. package/dist/admin/shell/session.d.ts +6 -0
  34. package/dist/admin/shell/session.js +17 -2
  35. package/dist/admin/shell/settings.d.ts +15 -0
  36. package/dist/admin/shell/settings.js +18 -0
  37. package/dist/admin/supabase-auth.js +7 -6
  38. package/dist/admin/ui/theme.js +13 -0
  39. package/dist/index.d.ts +5 -0
  40. package/dist/index.js +3 -0
  41. package/dist/member-store.d.ts +49 -0
  42. package/dist/member-store.js +60 -0
  43. package/dist/permissions.d.ts +7 -0
  44. package/dist/permissions.js +19 -0
  45. package/dist/sql-space.js +29 -15
  46. package/dist/supabase-member-store.d.ts +15 -0
  47. package/dist/supabase-member-store.js +78 -0
  48. package/package.json +1 -1
@@ -0,0 +1,60 @@
1
+ export const memberErrors = {
2
+ notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
3
+ noAccount: (email) => new Error(`smoodly: no Auth account for "${email}" although the invite said it exists.`),
4
+ };
5
+ /** Email first (case-insensitive), rows without one last, ties by user id. */
6
+ export function sortMembers(rows) {
7
+ return [...rows].sort((a, b) => {
8
+ if (a.email === null && b.email === null)
9
+ return a.userId.localeCompare(b.userId);
10
+ if (a.email === null)
11
+ return 1;
12
+ if (b.email === null)
13
+ return -1;
14
+ return a.email.localeCompare(b.email, undefined, { sensitivity: "base" }) || a.userId.localeCompare(b.userId);
15
+ });
16
+ }
17
+ export class MemoryMemberStore {
18
+ constructor(rows = [], url = null,
19
+ /** Auth accounts that exist without a member row: email → user id. */
20
+ accounts = {}) {
21
+ this.rows = rows;
22
+ this.url = url;
23
+ this.accounts = accounts;
24
+ /** Emails an invite was "sent" to, in order — what a test asserts. */
25
+ this.sent = [];
26
+ }
27
+ async list() {
28
+ return sortMembers(this.rows);
29
+ }
30
+ async consoleUrl() {
31
+ return this.url;
32
+ }
33
+ async invite(input) {
34
+ const key = input.email.toLowerCase();
35
+ let userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId;
36
+ let invited = false;
37
+ if (!userId) {
38
+ userId = crypto.randomUUID();
39
+ this.accounts[key] = userId;
40
+ this.sent.push(input.email);
41
+ invited = true;
42
+ }
43
+ const member = { userId, email: input.email, role: input.role };
44
+ this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
45
+ return { member, invited };
46
+ }
47
+ async setRole(userId, role) {
48
+ const row = this.rows.find((r) => r.userId === userId);
49
+ if (!row)
50
+ throw memberErrors.notFound(userId);
51
+ const next = { ...row, role };
52
+ this.rows = this.rows.map((r) => (r.userId === userId ? next : r));
53
+ return next;
54
+ }
55
+ async remove(userId) {
56
+ if (!this.rows.some((r) => r.userId === userId))
57
+ throw memberErrors.notFound(userId);
58
+ this.rows = this.rows.filter((r) => r.userId !== userId);
59
+ }
60
+ }
@@ -0,0 +1,7 @@
1
+ export declare const MEMBER_ROLES: readonly ["admin", "editor"];
2
+ export type MemberRole = (typeof MEMBER_ROLES)[number];
3
+ export type Capability = "content.write" | "settings" | "members.manage";
4
+ export declare function can(role: MemberRole, capability: Capability): boolean;
5
+ export declare const isMemberRole: (v: unknown) => v is MemberRole;
6
+ /** What the table holds → a role: `owner` (the pre-2026-09-25 word) is admin; anything unknown reads as editor, the safer side. */
7
+ export declare function normalizeRole(stored: string): MemberRole;
@@ -0,0 +1,19 @@
1
+ // Roles and what each may do (spec 2026-09-25 members management §1–§2).
2
+ // The ONE place a role string is read: the ops and the shell ask can(),
3
+ // the gate normalizes what the table holds. A third role is one row in
4
+ // GRANTS; a stored matrix, if ever wanted, replaces GRANTS behind can().
5
+ export const MEMBER_ROLES = ["admin", "editor"];
6
+ const GRANTS = {
7
+ admin: ["content.write", "settings", "members.manage"],
8
+ editor: ["content.write"],
9
+ };
10
+ export function can(role, capability) {
11
+ return GRANTS[role].includes(capability);
12
+ }
13
+ export const isMemberRole = (v) => typeof v === "string" && MEMBER_ROLES.includes(v);
14
+ /** What the table holds → a role: `owner` (the pre-2026-09-25 word) is admin; anything unknown reads as editor, the safer side. */
15
+ export function normalizeRole(stored) {
16
+ if (stored === "owner")
17
+ return "admin";
18
+ return isMemberRole(stored) ? stored : "editor";
19
+ }
package/dist/sql-space.js CHANGED
@@ -30,7 +30,7 @@ language sql stable as $$
30
30
  $$;`.trim();
31
31
  }
32
32
  /** Which rows a read key (or the anon key, which has no claim) may see.
33
- * null = write keys only — refs is editor tooling; editors is admission
33
+ * null = write keys only — refs is editor tooling; members is admission
34
34
  * data; nothing a browser needs. Global content (spec 2026-09-07) rides
35
35
  * the three entry predicates: it is stored as entries. */
36
36
  const PUBLISHED_READ = {
@@ -41,7 +41,7 @@ const PUBLISHED_READ = {
41
41
  entry_locales: `"status" = 'published'`,
42
42
  entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
43
43
  refs: null,
44
- editors: null,
44
+ members: null,
45
45
  assets: `"status" = 'ready'`,
46
46
  paths: `(("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')))`,
47
47
  };
@@ -52,13 +52,17 @@ const NEEDS_SPACE_INDEX = [
52
52
  export function spaceLayerSQL() {
53
53
  const statements = [];
54
54
  statements.push(`
55
- -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
56
- -- for everyone with ONE definition. Self-host fills them by hand or
57
- -- leaves them empty; smoodly.io writes rows with the service role.
58
- -- There is never cloud-only SQL on a content database.
59
- create table if not exists "editors" (
55
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
56
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
57
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
58
+ -- role. There is never cloud-only SQL on a content database.
59
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
60
+ -- email is stored so the Members page needs no Auth lookup — the auth
61
+ -- project is not the content project in the cloud.
62
+ create table if not exists "members" (
60
63
  ${SPACE_COL},
61
64
  "user_id" uuid not null,
65
+ "email" text,
62
66
  "role" text not null default 'editor',
63
67
  primary key ("space_id", "user_id")
64
68
  );
@@ -70,14 +74,18 @@ create table if not exists "space_keys" (
70
74
  "revoked_at" timestamptz
71
75
  );
72
76
 
73
- create table if not exists "space_caps" (
77
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
78
+ -- row = self-host: no cap, members managed in /admin. console_url set =
79
+ -- smoodly.io manages the members and /admin links there.
80
+ create table if not exists "spaces" (
74
81
  "space_id" uuid primary key,
75
- "row_cap" integer not null
82
+ "row_cap" integer,
83
+ "console_url" text
76
84
  );
77
85
 
78
- alter table "editors" enable row level security;
86
+ alter table "members" enable row level security;
79
87
  alter table "space_keys" enable row level security;
80
- alter table "space_caps" enable row level security;
88
+ alter table "spaces" enable row level security;
81
89
 
82
90
  -- ── Claim readers. The two lookups run as definer so they can read the
83
91
  -- service-role-only tables from inside a policy; search_path is pinned.
@@ -96,7 +104,7 @@ $$;
96
104
 
97
105
  create or replace function "smoodly_space_row_cap"() returns integer
98
106
  language sql stable security definer set search_path = public as $$
99
- select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
107
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
100
108
  $$;
101
109
 
102
110
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
@@ -113,7 +121,7 @@ begin
113
121
  end;
114
122
  $$;
115
123
 
116
- -- Per table, per space. No space_caps row = no cap (self-host). Trigger
124
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
117
125
  -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
118
126
  -- current space for a keyed insert; smoodly_space_row_cap() reads the
119
127
  -- current space itself, so this never needs the row's space_id as an
@@ -148,7 +156,7 @@ $$;`.trim());
148
156
  statements.push(`create index if not exists "${table}_space_idx" on "${table}" ("space_id");`);
149
157
  }
150
158
  // Trigger names sort a_ before b_: same-event triggers fire alphabetically.
151
- for (const table of [...CONTENT_TABLES, "editors"]) {
159
+ for (const table of [...CONTENT_TABLES, "members"]) {
152
160
  statements.push(`drop trigger if exists "smoodly_a_set_space" on "${table}";\n` +
153
161
  `create trigger "smoodly_a_set_space" before insert on "${table}"\n` +
154
162
  ` for each row execute function "smoodly_set_space"();`);
@@ -166,7 +174,7 @@ $$;`.trim());
166
174
  // Postgres never inlines; applied to all three calls for the same win.
167
175
  const own = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
168
176
  const write = `${own} and (select "smoodly_key_kind"()) = 'write'`;
169
- for (const table of [...CONTENT_TABLES, "editors"]) {
177
+ for (const table of [...CONTENT_TABLES, "members"]) {
170
178
  statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
171
179
  `create policy "smoodly_write" on "${table}" for all\n` +
172
180
  ` using (${write})\n` +
@@ -179,6 +187,12 @@ $$;`.trim());
179
187
  ` using (${predicate});`);
180
188
  }
181
189
  }
190
+ // A write key reads its own space's row — the console URL the Members
191
+ // page links to (spec 2026-09-25 §3). Nothing else touches spaces or
192
+ // space_keys but the two definer functions and the service role.
193
+ statements.push(`drop policy if exists "smoodly_read" on "spaces";\n` +
194
+ `create policy "smoodly_read" on "spaces" for select\n` +
195
+ ` using (${write});`);
182
196
  statements.push(storageSQL());
183
197
  return statements.join("\n\n");
184
198
  }
@@ -0,0 +1,15 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { type InviteInput, type InviteResult, type MemberRecord, type MemberStore } from "./member-store.ts";
3
+ import type { MemberRole } from "./permissions.ts";
4
+ export declare class SupabaseMemberStore implements MemberStore {
5
+ private db;
6
+ constructor(db: SupabaseClient);
7
+ list(): Promise<MemberRecord[]>;
8
+ consoleUrl(): Promise<string | null>;
9
+ private toRecord;
10
+ /** The admin API has no lookup by email: page through the users (tens, not thousands). */
11
+ private userIdByEmail;
12
+ invite(input: InviteInput): Promise<InviteResult>;
13
+ setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
14
+ remove(userId: string): Promise<void>;
15
+ }
@@ -0,0 +1,78 @@
1
+ import { memberErrors, sortMembers } from "./member-store.js";
2
+ export class SupabaseMemberStore {
3
+ constructor(db) {
4
+ this.db = db;
5
+ }
6
+ async list() {
7
+ const res = await this.db.from("members").select("user_id, email, role");
8
+ if (res.error)
9
+ throw new Error(`smoodly: ${res.error.message}`);
10
+ return sortMembers(res.data.map((r) => this.toRecord(r)));
11
+ }
12
+ async consoleUrl() {
13
+ const res = await this.db.from("spaces").select("console_url").limit(1).maybeSingle();
14
+ if (res.error)
15
+ throw new Error(`smoodly: ${res.error.message}`);
16
+ return res.data?.console_url ?? null;
17
+ }
18
+ toRecord(row) {
19
+ return { userId: row.user_id, email: row.email, role: row.role };
20
+ }
21
+ /** The admin API has no lookup by email: page through the users (tens, not thousands). */
22
+ async userIdByEmail(email) {
23
+ const wanted = email.toLowerCase();
24
+ for (let page = 1; page <= 50; page++) {
25
+ const res = await this.db.auth.admin.listUsers({ page, perPage: 200 });
26
+ if (res.error)
27
+ throw new Error(`smoodly: ${res.error.message}`);
28
+ const hit = res.data.users.find((u) => u.email?.toLowerCase() === wanted);
29
+ if (hit)
30
+ return hit.id;
31
+ if (res.data.users.length < 200)
32
+ break;
33
+ }
34
+ throw memberErrors.noAccount(email);
35
+ }
36
+ async invite(input) {
37
+ // Self-host: the content client is the auth project's service role, so
38
+ // the admin API is reachable. A cloud write key never gets here — the
39
+ // ops refuse behind the console URL first.
40
+ const sent = await this.db.auth.admin.inviteUserByEmail(input.email, { redirectTo: input.redirectTo });
41
+ let userId;
42
+ let invited;
43
+ if (!sent.error) {
44
+ userId = sent.data.user.id;
45
+ invited = true;
46
+ }
47
+ else if (sent.error.code === "email_exists" || /already (been )?registered/i.test(sent.error.message)) {
48
+ userId = await this.userIdByEmail(input.email);
49
+ invited = false;
50
+ }
51
+ else {
52
+ throw new Error(`smoodly: ${sent.error.message}`);
53
+ }
54
+ const res = await this.db
55
+ .from("members")
56
+ .upsert({ user_id: userId, email: input.email, role: input.role }, { onConflict: "space_id,user_id" })
57
+ .select("user_id, email, role")
58
+ .single();
59
+ if (res.error)
60
+ throw new Error(`smoodly: ${res.error.message}`);
61
+ return { member: this.toRecord(res.data), invited };
62
+ }
63
+ async setRole(userId, role) {
64
+ const res = await this.db.from("members").update({ role }).eq("user_id", userId).select("user_id, email, role").maybeSingle();
65
+ if (res.error)
66
+ throw new Error(`smoodly: ${res.error.message}`);
67
+ if (!res.data)
68
+ throw memberErrors.notFound(userId);
69
+ return this.toRecord(res.data);
70
+ }
71
+ async remove(userId) {
72
+ const res = await this.db.from("members").delete().eq("user_id", userId).select("user_id");
73
+ if (res.error)
74
+ throw new Error(`smoodly: ${res.error.message}`);
75
+ if (!res.data || res.data.length === 0)
76
+ throw memberErrors.notFound(userId);
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {