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.
- package/README.md +38 -3
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/index.d.ts +3 -1
- package/dist/admin/next/index.d.ts +3 -1
- package/dist/admin/next/op-handler.d.ts +3 -0
- package/dist/admin/next/op-handler.js +1 -0
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +78 -1
- package/dist/admin/ops.d.ts +21 -0
- package/dist/admin/shell/AdminApp.js +28 -2
- package/dist/admin/shell/EntryForm.js +17 -0
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +24 -0
- package/dist/admin/shell/ListView.d.ts +3 -1
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MembersPage.d.ts +6 -0
- package/dist/admin/shell/MembersPage.js +83 -0
- package/dist/admin/shell/PagesList.js +7 -0
- package/dist/admin/shell/SetPasswordView.d.ts +7 -0
- package/dist/admin/shell/SetPasswordView.js +41 -0
- package/dist/admin/shell/SettingsView.d.ts +9 -0
- package/dist/admin/shell/SettingsView.js +49 -0
- package/dist/admin/shell/live-url.d.ts +9 -0
- package/dist/admin/shell/live-url.js +10 -0
- package/dist/admin/shell/members-list.d.ts +21 -0
- package/dist/admin/shell/members-list.js +21 -0
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/shell/settings.d.ts +15 -0
- package/dist/admin/shell/settings.js +18 -0
- package/dist/admin/supabase-auth.js +7 -6
- package/dist/admin/ui/theme.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/member-store.d.ts +49 -0
- package/dist/member-store.js +60 -0
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/sql-space.js +29 -15
- package/dist/supabase-member-store.d.ts +15 -0
- package/dist/supabase-member-store.js +78 -0
- 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;
|
|
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
|
-
|
|
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
|
|
56
|
-
-- for everyone with ONE definition. Self-host fills them
|
|
57
|
-
-- leaves them empty; smoodly.io writes rows with the service
|
|
58
|
-
-- There is never cloud-only SQL on a content database.
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
82
|
+
"row_cap" integer,
|
|
83
|
+
"console_url" text
|
|
76
84
|
);
|
|
77
85
|
|
|
78
|
-
alter table "
|
|
86
|
+
alter table "members" enable row level security;
|
|
79
87
|
alter table "space_keys" enable row level security;
|
|
80
|
-
alter table "
|
|
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 "
|
|
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
|
|
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, "
|
|
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, "
|
|
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