smoodly 0.0.37 → 0.0.39

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 (46) hide show
  1. package/dist/admin/fixed-nodes.d.ts +1 -1
  2. package/dist/admin/fixed-nodes.js +26 -1
  3. package/dist/admin/index.d.ts +1 -0
  4. package/dist/admin/index.js +1 -0
  5. package/dist/admin/ops-impl.js +34 -9
  6. package/dist/admin/shell/SchemaNotice.js +1 -1
  7. package/dist/admin/supabase-auth.js +10 -4
  8. package/dist/cli/add-admin.d.ts +11 -0
  9. package/dist/cli/add-admin.js +25 -0
  10. package/dist/cli/main.d.ts +5 -1
  11. package/dist/cli/main.js +23 -3
  12. package/dist/cli/migrate.d.ts +1 -1
  13. package/dist/cli/migrate.js +6 -1
  14. package/dist/cli/space-io.d.ts +1 -7
  15. package/dist/cli/space-io.js +2 -16
  16. package/dist/client.js +3 -1
  17. package/dist/content/content-api.d.ts +84 -0
  18. package/dist/content/content-api.js +170 -0
  19. package/dist/content/node.d.ts +7 -0
  20. package/dist/content/node.js +10 -0
  21. package/dist/env.d.ts +6 -3
  22. package/dist/env.js +39 -15
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.js +1 -0
  25. package/dist/member-store.d.ts +12 -0
  26. package/dist/member-store.js +8 -0
  27. package/dist/model-key.d.ts +1 -0
  28. package/dist/model-key.js +36 -0
  29. package/dist/next/page-renderer.js +6 -5
  30. package/dist/next/queries.js +1 -1
  31. package/dist/schema-read.d.ts +7 -0
  32. package/dist/schema-read.js +15 -0
  33. package/dist/schema-version.d.ts +2 -0
  34. package/dist/schema-version.js +3 -1
  35. package/dist/schema.d.ts +10 -4
  36. package/dist/site.d.ts +8 -0
  37. package/dist/site.js +40 -2
  38. package/dist/supabase-entry-store.js +53 -37
  39. package/dist/supabase-member-store.d.ts +6 -0
  40. package/dist/supabase-member-store.js +20 -2
  41. package/dist/supabase-store.js +16 -14
  42. package/dist/tags.d.ts +3 -0
  43. package/dist/tags.js +16 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/version.js +2 -0
  46. package/package.json +16 -13
@@ -0,0 +1,170 @@
1
+ // The content API (spec 2026-09-30 tester findings §1): the admin's
2
+ // operations with no signed-in editor, as a few verbs that throw. Scripts
3
+ // and route handlers write content through this, never through the stores,
4
+ // so validation, the code-owned address guards and publish checks apply.
5
+ import { createAdminOps } from "../admin/ops-impl.js";
6
+ import { imageDimensions } from "../image-dimensions.js";
7
+ const MIME = {
8
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", avif: "image/avif",
9
+ svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf",
10
+ };
11
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+ export class SmoodlyContentError extends Error {
13
+ constructor(error) {
14
+ const list = (error.fields ?? []).map((f) => `\n ${f.field}: ${f.message}`).join("");
15
+ super(error.message + list);
16
+ this.name = "SmoodlyContentError";
17
+ this.code = error.code;
18
+ this.fields = error.fields ?? [];
19
+ }
20
+ }
21
+ async function must(result) {
22
+ const r = await result;
23
+ if (!r.ok)
24
+ throw new SmoodlyContentError(r);
25
+ return r.data;
26
+ }
27
+ /** With a password: the account is created (or an existing one kept) and made an admin. Without: an invite mail,
28
+ * whose link goes to `redirectTo` or the Auth project's Site URL. Running it again changes nothing.
29
+ * Its own function because `smoodly add-admin` has a member store and no config. */
30
+ export async function addAdmin(members, email, options = {}) {
31
+ if (!members)
32
+ throw new SmoodlyContentError({ code: "internal", message: "smoodly: no member store — pass `members` to createSmoodlyContent." });
33
+ if (!EMAIL.test(email))
34
+ throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${email}" is not an email address.` });
35
+ if (options.password !== undefined && options.password.length < 8) {
36
+ throw new SmoodlyContentError({ code: "validation", message: "smoodly: the password needs at least 8 characters." });
37
+ }
38
+ try {
39
+ // Where smoodly.io manages the members, nothing here writes them — as the admin's Members page refuses.
40
+ if ((await members.consoleUrl()) !== null) {
41
+ throw new SmoodlyContentError({ code: "blocked", message: "smoodly: this site's members are managed at smoodly.io." });
42
+ }
43
+ if (options.password !== undefined) {
44
+ await members.createAccount({ email, password: options.password, role: "admin" });
45
+ return { email, invited: false };
46
+ }
47
+ const { invited } = await members.invite({ email, role: "admin", redirectTo: options.redirectTo ?? "" });
48
+ return { email, invited };
49
+ }
50
+ catch (e) {
51
+ if (e instanceof SmoodlyContentError)
52
+ throw e;
53
+ throw new SmoodlyContentError({ code: "internal", message: e instanceof Error ? e.message : String(e) });
54
+ }
55
+ }
56
+ export function createSmoodlyContent(deps) {
57
+ const ops = createAdminOps(deps);
58
+ const def = deps.config.locales.default;
59
+ async function findPage(locale, path) {
60
+ const hit = await deps.pages.resolvePath(locale, path);
61
+ if (hit?.kind === "page" && !hit.redirect)
62
+ return deps.pages.getPage(hit.id);
63
+ return null;
64
+ }
65
+ async function updateEntry(collection, id, locale, fields) {
66
+ return must(ops.entries.save(collection, id, locale, fields));
67
+ }
68
+ /** The entries present in `locale` (the default language unless given) whose fields match every `where` key:
69
+ * shared and that locale's fields and its slug, equal, or held by an array field (tags, refList). */
70
+ async function listEntries(collection, options = {}) {
71
+ if (!deps.config.registry.collections?.some((c) => c.name === collection)) {
72
+ throw new SmoodlyContentError({ code: "not_found", message: `smoodly: no collection named "${collection}".` });
73
+ }
74
+ const locale = options.locale ?? def;
75
+ const where = Object.entries(options.where ?? {});
76
+ return (await must(ops.entries.list(collection))).filter((e) => {
77
+ const row = e.locales[locale];
78
+ if (!row)
79
+ return false;
80
+ const fields = { ...e.fields, ...row.fields, slug: row.slug };
81
+ return where.every(([key, want]) => {
82
+ const value = fields[key];
83
+ return Array.isArray(value) ? value.includes(want) : value === want;
84
+ });
85
+ });
86
+ }
87
+ return {
88
+ pages: {
89
+ create: (input) => must(ops.pages.create(input)),
90
+ async setTree(id, locale, tree) {
91
+ await must(ops.pages.saveDraft(id, locale, tree));
92
+ },
93
+ /** The locale's draft tree — what `setTree` replaces; null for a folder or a locale the page lacks.
94
+ * A seed reads it to tell a code-owned page nobody has filled yet. */
95
+ async getTree(id, locale) {
96
+ return (await must(ops.pages.get(id, locale))).draft;
97
+ },
98
+ async addLocale(id, locale, options) {
99
+ const added = await must(ops.pages.addLocale(id, locale, { from: options.from }));
100
+ if (options.slug === undefined && options.title === undefined)
101
+ return added;
102
+ return must(ops.pages.rename(id, {
103
+ locale,
104
+ ...(options.slug !== undefined ? { slug: options.slug } : {}),
105
+ ...(options.title !== undefined ? { title: options.title } : {}),
106
+ redirect: false,
107
+ }));
108
+ },
109
+ publish: (id, locale) => must(ops.pages.publish(id, locale)),
110
+ /** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
111
+ async find(locale, path) {
112
+ const found = await findPage(locale, path);
113
+ if (found)
114
+ return found;
115
+ await must(ops.pages.list());
116
+ return findPage(locale, path);
117
+ },
118
+ },
119
+ entries: {
120
+ create: (collection, input) => must(ops.entries.create(collection, input)),
121
+ update: updateEntry,
122
+ async addLocale(collection, id, locale, options) {
123
+ const added = await must(ops.entries.addLocale(collection, id, locale, { from: options.from }));
124
+ return options.fields ? updateEntry(collection, id, locale, options.fields) : added;
125
+ },
126
+ publish: (collection, id, locale) => must(ops.entries.setStatus(collection, id, locale, "published")),
127
+ list: listEntries,
128
+ async find(collection, match) {
129
+ return (await listEntries(collection, { where: { slug: match.slug }, locale: match.locale }))[0] ?? null;
130
+ },
131
+ },
132
+ globals: {
133
+ async set(name, locale, fields) {
134
+ await must(ops.globals.list()); // materializes the item (ensureGlobalItems), as opening Globals does
135
+ return must(ops.globals.save(name, locale, fields));
136
+ },
137
+ publish: (name, locale) => must(ops.globals.setStatus(name, locale, "published")),
138
+ },
139
+ media: {
140
+ /** A file path or bytes in, a ready asset and the value an image field stores out. */
141
+ async upload(file, meta = {}) {
142
+ if (!deps.assets)
143
+ throw new SmoodlyContentError({ code: "internal", message: "smoodly: no media store — pass `assets` to createSmoodlyContent." });
144
+ // Loaded here, not at the top: this module is reachable from `smoodly/admin`, which a bundler may walk.
145
+ const bytes = typeof file === "string" ? new Uint8Array(await (await import("node:fs/promises")).readFile(file)) : file;
146
+ const filename = meta.filename ?? (typeof file === "string" ? file.split(/[\\/]/).pop() : "upload");
147
+ const mime = meta.mime ?? MIME[filename.split(".").pop().toLowerCase()] ?? "application/octet-stream";
148
+ // The media library's own rules, as the admin's upload applies them (config.media).
149
+ const { accept, maxSize } = deps.config.media;
150
+ if (!accept.includes(mime))
151
+ throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${filename}" is ${mime}, not an accepted file type.` });
152
+ if (bytes.byteLength > maxSize) {
153
+ throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${filename}" is larger than ${Math.round(maxSize / 1024 / 1024)} MB.` });
154
+ }
155
+ const { alt, title, caption, credit } = meta;
156
+ let asset;
157
+ try {
158
+ asset = await deps.assets.create({ filename, mime, size: bytes.byteLength, bytes, alt, title, caption, credit, ...(imageDimensions(bytes) ?? {}) });
159
+ }
160
+ catch (e) {
161
+ throw new SmoodlyContentError({ code: "internal", message: e instanceof Error ? e.message : String(e) });
162
+ }
163
+ return { asset: asset.id, ...(alt !== undefined ? { alt } : {}) };
164
+ },
165
+ },
166
+ members: {
167
+ addAdmin: (email, options = {}) => addAdmin(deps.members, email, options),
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,7 @@
1
+ import type { BuilderMap, SectionSchema, StoredValues } from "../schema.ts";
2
+ import type { PairedComponent } from "../pairing.tsx";
3
+ import type { TreeNode } from "../render.tsx";
4
+ export declare function node<F extends BuilderMap, St extends BuilderMap>(section: PairedComponent<SectionSchema<F, St>, F, St>, fields: Partial<StoredValues<F>>, options?: {
5
+ settings?: Partial<StoredValues<St>>;
6
+ id?: string;
7
+ }): TreeNode;
@@ -0,0 +1,10 @@
1
+ import { newNodeId } from "../admin/tree-ops.js";
2
+ export function node(section, fields, options = {}) {
3
+ const type = section.schema.name;
4
+ return {
5
+ id: options.id ?? newNodeId(type),
6
+ type,
7
+ fields: fields,
8
+ ...(options.settings ? { settings: options.settings } : {}),
9
+ };
10
+ }
package/dist/env.d.ts CHANGED
@@ -4,17 +4,20 @@ export type SmoodlyEnv = {
4
4
  url: string;
5
5
  /** Server-only: the service-role key (self-host) or the environment's write key (cloud). */
6
6
  secretKey: string;
7
- /** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). */
7
+ /** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). "" on a live site that runs without the admin. */
8
8
  publishableKey: string;
9
9
  /** Where editors sign in. Defaults to `url`. */
10
10
  authUrl: string;
11
11
  /** That project's publishable key. Defaults to `publishableKey`. */
12
12
  authKey: string;
13
13
  };
14
- export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>): SmoodlyEnv;
14
+ /** The live site reads with the URL and the secret key; the admin and presence also need the
15
+ * publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
16
+ * in the wrong variable or of no known form is named here, not by a failed read later. */
17
+ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>, need?: "site" | "admin"): SmoodlyEnv;
15
18
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
16
19
  * so `next build` without env still succeeds. */
17
- export declare const smoodlyEnv: () => SmoodlyEnv;
20
+ export declare const smoodlyEnv: (need?: "site" | "admin") => SmoodlyEnv;
18
21
  /** What the browser needs to join the admin's presence channel: the content project's URL, publishable key and space, public values only — never for content reads (spec 2026-09-29 content API contract §8). */
19
22
  export type PresenceConfig = {
20
23
  url: string;
package/dist/env.js CHANGED
@@ -1,27 +1,51 @@
1
1
  // The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
2
- // names; the cloud sets five. The two defaults are what keep a single
2
+ // names (the live site alone needs two); the cloud sets five. The two defaults are what keep a single
3
3
  // code path: a default for an absent value is not a mode branch.
4
4
  import { createSmoodlyClient } from "./client.js";
5
5
  import { readCurrentSpace } from "./current-space.js";
6
- const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
7
- export function resolveSmoodlyEnv(env) {
8
- const missing = REQUIRED.filter((name) => !env[name]);
9
- if (missing.length > 0) {
10
- throw new Error(`smoodly: set ${missing.join(", ")} — see .env.local.example.`);
6
+ const SITE_NEEDS = ["SMOODLY_URL", "SMOODLY_SECRET_KEY"];
7
+ const ADMIN_NEEDS = [...SITE_NEEDS, "SMOODLY_PUBLISHABLE_KEY"];
8
+ const DEPLOYING = "https://smoodly.io/docs/getting-started/deploying";
9
+ const isJwt = (v) => v.split(".").length === 3;
10
+ /** A legacy JWT key's role claim ("anon", "service_role"; "authenticated" for a cloud space key); null when it cannot be read. */
11
+ function jwtRole(v) {
12
+ if (!isJwt(v))
13
+ return null;
14
+ try {
15
+ const role = JSON.parse(atob(v.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).role;
16
+ return typeof role === "string" ? role : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /** The live site reads with the URL and the secret key; the admin and presence also need the
23
+ * publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
24
+ * in the wrong variable or of no known form is named here, not by a failed read later. */
25
+ export function resolveSmoodlyEnv(env, need = "site") {
26
+ const missing = (need === "admin" ? ADMIN_NEEDS : SITE_NEEDS).filter((name) => !env[name]);
27
+ if (missing.length > 0)
28
+ throw new Error(`smoodly: set ${missing.join(", ")} in .env.local (Deploying: ${DEPLOYING}).`);
29
+ const secretKey = env.SMOODLY_SECRET_KEY;
30
+ const publishableKey = env.SMOODLY_PUBLISHABLE_KEY ?? "";
31
+ if (secretKey.startsWith("sb_publishable_") || jwtRole(secretKey) === "anon") {
32
+ throw new Error("smoodly: SMOODLY_SECRET_KEY holds a publishable key — copy the secret (service-role) key from your Supabase project.");
33
+ }
34
+ if (!secretKey.startsWith("sb_secret_") && !isJwt(secretKey)) {
35
+ throw new Error("smoodly: SMOODLY_SECRET_KEY is neither an sb_secret_ key nor a JWT — copy the secret (service-role) key from your Supabase project.");
36
+ }
37
+ if (publishableKey.startsWith("sb_secret_") || jwtRole(publishableKey) === "service_role") {
38
+ throw new Error("smoodly: SMOODLY_PUBLISHABLE_KEY holds a secret key — it reaches the browser; copy the publishable (anon) key from your Supabase project.");
39
+ }
40
+ if (publishableKey && !publishableKey.startsWith("sb_publishable_") && !isJwt(publishableKey)) {
41
+ throw new Error("smoodly: SMOODLY_PUBLISHABLE_KEY is neither an sb_publishable_ key nor a JWT — copy the publishable (anon) key from your Supabase project.");
11
42
  }
12
43
  const url = env.SMOODLY_URL;
13
- const publishableKey = env.SMOODLY_PUBLISHABLE_KEY;
14
- return {
15
- url,
16
- secretKey: env.SMOODLY_SECRET_KEY,
17
- publishableKey,
18
- authUrl: env.SMOODLY_AUTH_URL || url,
19
- authKey: env.SMOODLY_AUTH_KEY || publishableKey,
20
- };
44
+ return { url, secretKey, publishableKey, authUrl: env.SMOODLY_AUTH_URL || url, authKey: env.SMOODLY_AUTH_KEY || publishableKey };
21
45
  }
22
46
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
23
47
  * so `next build` without env still succeeds. */
24
- export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
48
+ export const smoodlyEnv = (need = "site") => resolveSmoodlyEnv(process.env, need);
25
49
  /** One lookup per process per content project and key: the space never
26
50
  * changes for a key. A failed lookup is not cached, so the next call retries. */
27
51
  const spaces = new Map();
package/dist/index.d.ts CHANGED
@@ -66,7 +66,8 @@ export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStor
66
66
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
67
67
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
68
68
  export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
69
- export type { SectionSchema, ElementSchema, BuilderMap, Values } from "./schema.ts";
69
+ export type { SectionSchema, ElementSchema, BuilderMap, Values, StoredValues } from "./schema.ts";
70
+ export { node } from "./content/node.ts";
70
71
  export type { CollectionSeo } from "./seo.ts";
71
72
  export type { EntryRef, EntryItem, PageItem, GlobalItem, VisualGlobalItem, Seo } from "./read-shapes.ts";
72
73
  export type { RefTo } from "./fields.ts";
@@ -97,7 +98,7 @@ export declare const smoodly: {
97
98
  omit: string[];
98
99
  };
99
100
  css?: false;
100
- sample?: Partial<{ [K in keyof import("./schema.ts").NamespaceValues<F>]: import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<F>[K]; } & { [K_1 in keyof import("./schema.ts").NamespaceValues<S>]: import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<S>[K_1]; }> | undefined;
101
+ sample?: Partial<import("./schema.ts").StoredValues<F> & import("./schema.ts").StoredValues<S>> | undefined;
101
102
  }): import("./schema.ts").SectionSchema<F, S>;
102
103
  element<F extends import("./schema.ts").BuilderMap = {}, S extends import("./schema.ts").BuilderMap = {}>(input: {
103
104
  name: string;
@@ -111,7 +112,7 @@ export declare const smoodly: {
111
112
  omit: string[];
112
113
  };
113
114
  css?: false;
114
- sample?: Partial<{ [K in keyof import("./schema.ts").NamespaceValues<F>]: import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<F>[K]; } & { [K_1 in keyof import("./schema.ts").NamespaceValues<S>]: import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<S>[K_1]; }> | undefined;
115
+ sample?: Partial<import("./schema.ts").StoredValues<F> & import("./schema.ts").StoredValues<S>> | undefined;
115
116
  } & {
116
117
  contexts: ("richtext" | "column")[];
117
118
  }): import("./schema.ts").ElementSchema<F, S>;
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export { fixedPathFor } from "./localize.js";
46
46
  export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey } from "./addresses.js";
47
47
  export { anchorUrl, anchorsFor, candidates, fullPath, pickHit } from "./anchors.js";
48
48
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
49
+ export { node } from "./content/node.js";
49
50
  export { DEFAULT_TOOLSET } from "./toolset.js";
50
51
  export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
51
52
  export { GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, CSS_KEY, globalLink } from "./global.js";
@@ -21,6 +21,13 @@ export interface MemberStore {
21
21
  consoleUrl(): Promise<string | null>;
22
22
  /** Creates or finds the Auth user for the email, mails the invite when it created one, upserts the row (spec 2026-09-25 members management §3). */
23
23
  invite(input: InviteInput): Promise<InviteResult>;
24
+ /** Creates the Auth user with a password (confirmed, no mail) and upserts the row — the installer's first editor, `add-admin`.
25
+ * An existing account keeps its password and only gains the membership. */
26
+ createAccount(input: {
27
+ email: string;
28
+ password: string;
29
+ role: MemberRole;
30
+ }): Promise<MemberRecord>;
24
31
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
25
32
  /** Deletes the member row only; the Auth account stays. */
26
33
  remove(userId: string): Promise<void>;
@@ -44,6 +51,11 @@ export declare class MemoryMemberStore implements MemberStore {
44
51
  list(): Promise<MemberRecord[]>;
45
52
  consoleUrl(): Promise<string | null>;
46
53
  invite(input: InviteInput): Promise<InviteResult>;
54
+ createAccount(input: {
55
+ email: string;
56
+ password: string;
57
+ role: MemberRole;
58
+ }): Promise<MemberRecord>;
47
59
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
48
60
  remove(userId: string): Promise<void>;
49
61
  }
@@ -44,6 +44,14 @@ export class MemoryMemberStore {
44
44
  this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
45
45
  return { member, invited };
46
46
  }
47
+ async createAccount(input) {
48
+ const key = input.email.toLowerCase();
49
+ const userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId ?? crypto.randomUUID();
50
+ this.accounts[key] = userId;
51
+ const member = { userId, email: input.email, role: input.role };
52
+ this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
53
+ return member;
54
+ }
47
55
  async setRole(userId, role) {
48
56
  const row = this.rows.find((r) => r.userId === userId);
49
57
  if (!row)
@@ -0,0 +1 @@
1
+ export declare function modelKey(model: unknown): string;
@@ -0,0 +1,36 @@
1
+ // A short, stable hash of the registered model (spec 2026-09-30 tester findings §3):
2
+ // object keys sorted, functions dropped, cycles cut — two processes agree.
3
+ // Plain arithmetic, no node:crypto: the root entry is imported by client sections too.
4
+ function canonical(value, seen) {
5
+ if (typeof value === "function" || value === undefined)
6
+ return undefined;
7
+ if (value === null || typeof value !== "object")
8
+ return value;
9
+ if (seen.has(value))
10
+ return "[cycle]";
11
+ seen.add(value);
12
+ const out = Array.isArray(value)
13
+ ? value.map((v) => canonical(v, seen) ?? null)
14
+ : Object.fromEntries(Object.keys(value).sort().flatMap((k) => {
15
+ const v = canonical(value[k], seen);
16
+ return v === undefined ? [] : [[k, v]];
17
+ }));
18
+ seen.delete(value);
19
+ return out;
20
+ }
21
+ /** Two 32-bit FNV-1a style runs with different seeds (cyrb53's mixing): 64 bits, of which 48 are kept. */
22
+ function hash(text) {
23
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
24
+ for (let i = 0; i < text.length; i++) {
25
+ const c = text.charCodeAt(i);
26
+ h1 = Math.imul(h1 ^ c, 2654435761);
27
+ h2 = Math.imul(h2 ^ c, 1597334677);
28
+ }
29
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
30
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
31
+ const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
32
+ return (hex(h2) + hex(h1)).slice(0, 12);
33
+ }
34
+ export function modelKey(model) {
35
+ return hash(JSON.stringify(canonical(model, new WeakSet())));
36
+ }
@@ -39,7 +39,7 @@ export function createPageRenderer(options) {
39
39
  // Keyed by what the unit holds, not only where: an address that changes
40
40
  // owner — a deploy handing it to a code record, which fires no tag —
41
41
  // can never serve the previous occupant (spec 2026-09-29 pre-install fixes §5).
42
- return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path, target.kind, target.id], {
42
+ return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", site.cacheKey, locale, path, target.kind, target.id], {
43
43
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
44
44
  })();
45
45
  }
@@ -114,7 +114,8 @@ export function createPageRenderer(options) {
114
114
  const target = await site.locate(locale, path);
115
115
  if (target?.redirect)
116
116
  await redirectTo(target, locale, params);
117
- const hit = target ? await load(target, locale, path, draft, version) : null;
117
+ // A code-owned address with no record yet is served from code (spec 2026-09-30 tester findings §2).
118
+ const hit = target ? await load(target, locale, path, draft, version) : site.codePage(locale, path);
118
119
  if (!hit || !accept(hit))
119
120
  notFound();
120
121
  // The draft marker tells the page editor's canvas diagnosis that Smoodly
@@ -129,9 +130,9 @@ export function createPageRenderer(options) {
129
130
  const path = pathFor(locale);
130
131
  const { isEnabled: draft } = await draftMode();
131
132
  const target = await site.locate(locale, path);
132
- if (!target || target.redirect)
133
+ if (target?.redirect)
133
134
  return {};
134
- const hit = await load(target, locale, path, draft);
135
+ const hit = target ? await load(target, locale, path, draft) : site.codePage(locale, path);
135
136
  if (!hit || !accept(hit))
136
137
  return {};
137
138
  // A page type without seo: true still gets its page title as <title>; seo: true adds the panel's values on top.
@@ -153,7 +154,7 @@ export function createPageRenderer(options) {
153
154
  const { isEnabled: draft } = await draftMode();
154
155
  if (draft)
155
156
  return site.getGlobal(item, { locale, draft: true });
156
- return unstable_cache(() => site.getGlobal(item, { locale }), ["smoodly-global", item.name, locale], {
157
+ return unstable_cache(() => site.getGlobal(item, { locale }), ["smoodly-global", site.cacheKey, item.name, locale], {
157
158
  tags: [globalTag(item.name)],
158
159
  })();
159
160
  }
@@ -36,7 +36,7 @@ export function createSmoodlyQueries(options) {
36
36
  const { isEnabled: draft } = await draftMode();
37
37
  if (draft)
38
38
  return read(true);
39
- return unstable_cache(() => read(false), ["smoodly-q", ...key], { tags })();
39
+ return unstable_cache(() => read(false), ["smoodly-q", site.cacheKey, ...key], { tags })();
40
40
  }
41
41
  /** The tag needs the id: a path is located first, uncached, as the renderer does. */
42
42
  async function idOf(kind, locale, pathOrId) {
@@ -0,0 +1,7 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
3
+ export declare function readSchemaVersion(db: SupabaseClient): Promise<{
4
+ version: number;
5
+ } | {
6
+ missing: "key" | "schema";
7
+ }>;
@@ -0,0 +1,15 @@
1
+ import { SCHEMA_VERSION_KEY } from "./schema-version.js";
2
+ /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
3
+ export async function readSchemaVersion(db) {
4
+ const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
5
+ if (res.error) {
6
+ // PGRST205: not in PostgREST's schema cache; 42P01: undefined table; 42501: no grant.
7
+ if (res.error.code === "PGRST205" || res.error.code === "42P01")
8
+ return { missing: "schema" };
9
+ if (res.error.code === "42501")
10
+ return { missing: "key" };
11
+ throw new Error(`smoodly: reading the schema version: ${res.error.message}`);
12
+ }
13
+ const n = Number(res.data?.value);
14
+ return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
15
+ }
@@ -15,3 +15,5 @@ export declare function schemaVersionSQL(version: number): string;
15
15
  * bump, and is fine. */
16
16
  export declare const schemaBehind: (database: number | null, required?: number) => boolean;
17
17
  export declare function schemaBehindMessage(database: number | null, required?: number): string;
18
+ /** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
19
+ export declare const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version \u2014 it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
@@ -28,5 +28,7 @@ export function schemaVersionSQL(version) {
28
28
  export const schemaBehind = (database, required = SCHEMA_VERSION) => database === null || database < required;
29
29
  export function schemaBehindMessage(database, required = SCHEMA_VERSION) {
30
30
  return (`smoodly: the database schema is at version ${database ?? "none"}; this Smoodly needs version ${required}. ` +
31
- "Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).");
31
+ "Run `npx smoodly migrate`, then apply it: `supabase db push` (hosted) or `supabase migration up` (local).");
32
32
  }
33
+ /** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
34
+ export const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version — it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
package/dist/schema.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Descriptor, type FieldBuilder, type ValueOf } from "./fields.ts";
2
2
  import { pageSchema } from "./page.ts";
3
3
  import type { RefTo } from "./fields.ts";
4
4
  import type { EntryRef } from "./read-shapes.ts";
5
+ import type { FileValue, ImageValue, ResolvedFile, ResolvedImage, ResolvedVideo, VideoValue } from "./media.ts";
5
6
  export type BuilderMap = Record<string, FieldBuilder<any, any>>;
6
7
  export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
7
8
  kind: "section";
@@ -54,9 +55,14 @@ export type Materialize<V> = V extends RefTo<infer T, infer M> ? EntryRef<ShapeO
54
55
  export type Values<M extends BuilderMap> = {
55
56
  [K in keyof NamespaceValues<M>]: Materialize<NamespaceValues<M>[K]>;
56
57
  };
57
- /** A sample stores ids where a read shows references. */
58
- type Stored<M extends BuilderMap> = {
59
- [K in keyof NamespaceValues<M>]: NamespaceValues<M>[K] extends RefTo<any, any> ? string : NamespaceValues<M>[K] extends RefTo<any, any>[] ? string[] : NamespaceValues<M>[K];
58
+ /** One value as storage holds it: a ref is the entry's id, media is the `{ asset }` value, and lists and
59
+ * objects hold the same inside. Richtext (`unknown`) and scalars pass through. */
60
+ export type StoredOf<V> = unknown extends V ? V : V extends RefTo<any, any> ? string : V extends ResolvedImage ? ImageValue : V extends ResolvedVideo ? VideoValue : V extends ResolvedFile ? FileValue : V extends readonly (infer I)[] ? StoredOf<I>[] : V extends Record<string, unknown> ? {
61
+ [K in keyof V]: StoredOf<V[K]>;
62
+ } : V;
63
+ /** What storage holds for a field map (a sample, a node built from code). */
64
+ export type StoredValues<M extends BuilderMap> = {
65
+ [K in keyof NamespaceValues<M>]: StoredOf<NamespaceValues<M>[K]>;
60
66
  };
61
67
  type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
62
68
  name: string;
@@ -71,7 +77,7 @@ type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
71
77
  omit: string[];
72
78
  };
73
79
  css?: false;
74
- sample?: Partial<Stored<F> & Stored<S>>;
80
+ sample?: Partial<StoredValues<F> & StoredValues<S>>;
75
81
  };
76
82
  export declare const schema: {
77
83
  page: typeof pageSchema;
package/dist/site.d.ts CHANGED
@@ -108,6 +108,9 @@ export type LoadOptions = {
108
108
  export interface SmoodlySite {
109
109
  readonly locales: LocaleSet;
110
110
  readonly homePageSlug: string;
111
+ /** The package version and a hash of the registered model: a part of every cache key, so an upgrade or a
112
+ * model change never serves a unit cached in the old shape (spec 2026-09-30 tester findings §3). */
113
+ readonly cacheKey: string;
111
114
  /** The resolved styles values the renderer and `getGlobal` hand the wrapper. */
112
115
  readonly styles: StyleValues;
113
116
  /** The custom section's resolved options the renderer hands renderPage. */
@@ -119,6 +122,11 @@ export interface SmoodlySite {
119
122
  /** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
120
123
  load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
121
124
  resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
125
+ /** A fixed page type (or a collection's index page) as code alone describes it, when `path` is its address in
126
+ * `locale`: an empty tree, the page fields' defaults, the registration's title. The renderer serves it where
127
+ * the index has no row — a page type added after install, a locale added to the config — until the admin
128
+ * makes the record (spec 2026-09-30 tester findings §2). Not in `getPagesTree`: that lists records. */
129
+ codePage(locale: string, path: string): SitePage | null;
122
130
  getPagesTree(options?: {
123
131
  locale?: string;
124
132
  draft?: boolean;