smoodly 0.0.52 → 0.0.53

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 (51) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/files.generated.js +1 -1
  3. package/dist/admin/handler.d.ts +1 -1
  4. package/dist/admin/index.d.ts +2 -2
  5. package/dist/admin/ops.d.ts +4 -10
  6. package/dist/admin/page-data.d.ts +1 -1
  7. package/dist/admin/session-flow.d.ts +24 -2
  8. package/dist/admin/session-flow.js +29 -2
  9. package/dist/admin/shell/AdminApp.d.ts +1 -1
  10. package/dist/admin/shell/IconRail.js +5 -4
  11. package/dist/admin/shell/presence.d.ts +1 -1
  12. package/dist/admin/shell/session.d.ts +1 -2
  13. package/dist/admin/shell/session.js +10 -9
  14. package/dist/admin/ui/primitives.js +2 -1
  15. package/dist/api-client.js +1 -1
  16. package/dist/assemble.js +1 -1
  17. package/dist/astro/cache.d.ts +1 -1
  18. package/dist/astro/index.js +11 -6
  19. package/dist/astro/runtime.d.ts +1 -1
  20. package/dist/astro/runtime.js +1 -1
  21. package/dist/astro/shell.d.ts +1 -1
  22. package/dist/astro/shell.js +1 -1
  23. package/dist/content/content-api.d.ts +1 -0
  24. package/dist/content/content-api.js +3 -1
  25. package/dist/css.js +1 -1
  26. package/dist/entry-store.d.ts +5 -5
  27. package/dist/entry-store.js +7 -7
  28. package/dist/image/markup.js +1 -1
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +1 -1
  31. package/dist/media-cut.d.ts +1 -1
  32. package/dist/media-cut.js +2 -2
  33. package/dist/media.d.ts +1 -1
  34. package/dist/media.js +2 -2
  35. package/dist/member-store.d.ts +0 -29
  36. package/dist/member-store.js +0 -4
  37. package/dist/paths.d.ts +1 -1
  38. package/dist/paths.js +1 -1
  39. package/dist/revalidate.d.ts +1 -1
  40. package/dist/revalidate.js +1 -1
  41. package/dist/space-transfer.d.ts +1 -1
  42. package/dist/space-transfer.js +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
  46. package/dist/admin/auth-config.d.ts +0 -7
  47. package/dist/admin/auth-config.js +0 -1
  48. package/dist/admin/browser/fetch-op.d.ts +0 -10
  49. package/dist/admin/browser/fetch-op.js +0 -45
  50. package/dist/admin/browser/main.d.ts +0 -1
  51. package/dist/admin/browser/main.js +0 -27
@@ -3,7 +3,7 @@ import type { PresenceConfig } from "../env.ts";
3
3
  import { type KeySet } from "../signed-message.ts";
4
4
  import { type AdminFiles } from "./files.ts";
5
5
  import type { AdminEffects } from "./ops.ts";
6
- import type { SignInConfig } from "./auth-config.ts";
6
+ import type { SignInConfig } from "./session-flow.ts";
7
7
  export type AdminHandlerDeps = {
8
8
  config: ResolvedConfig;
9
9
  /** Where the browser sends a person to sign in, and the dashboard it links to: public values only. Absent, the page
@@ -1,10 +1,10 @@
1
1
  export type { AdminEffects, AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
2
- export type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
2
+ export type { MemberRecord } from "../member-store.ts";
3
3
  export type { MemberRole } from "../permissions.ts";
4
4
  export { fixedNodeSpecs, fixedNodeOf, type FixedNode } from "./fixed-nodes.ts";
5
5
  export { initialGlobalFields } from "./global-items.ts";
6
6
  export { makeClientOps } from "./client-ops.ts";
7
- export type { SignInConfig } from "./auth-config.ts";
7
+ export type { SignInConfig } from "./session-flow.ts";
8
8
  export { serializeAdminConfig } from "./serialize.ts";
9
9
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
10
10
  export * from "./tree-ops.ts";
@@ -3,7 +3,7 @@ import type { PageTree } from "../page-context.ts";
3
3
  import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
4
  import type { AssetRecord, AssetMeta } from "../media.ts";
5
5
  import type { AssetPage, AssetQuery } from "../asset-store.ts";
6
- import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
6
+ import type { MemberRecord } from "../member-store.ts";
7
7
  import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
9
9
  import type { DeployState } from "../deploy-state.ts";
@@ -126,10 +126,9 @@ export type AssetUsageItem = {
126
126
  title: string;
127
127
  href: string | null;
128
128
  };
129
- /** The Members page's read (spec 2026-09-25 §4): the rows and, when the control plane manages them, where. */
129
+ /** The Members page's read (spec 2026-09-25 §4): the rows; the dashboard manages them. */
130
130
  export type MembersListResult = {
131
131
  members: MemberRecord[];
132
- consoleUrl: string | null;
133
132
  };
134
133
  /** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
135
134
  /** The Deploy tile's state (spec 2026-10-09 static mode §6); `hook` false = SMOODLY_DEPLOY_HOOK is unset, Deploy disabled. */
@@ -254,18 +253,13 @@ export type AdminOps = {
254
253
  usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
255
254
  };
256
255
  members: {
257
- /** The space's members; admins only. `consoleUrl` set means smoodly.io manages them and every write below is blocked. */
256
+ /** The space's members, a read-only list; admins only. Invites, roles and removals are the dashboard's
257
+ * (spec 2026-10-16 sign-in and the dashboard §6). */
258
258
  list(): Promise<OpResult<MembersListResult>>;
259
259
  /** The caller's role; every member. The shell hides Settings from editors with it. */
260
260
  me(): Promise<OpResult<{
261
261
  role: MemberRole;
262
262
  }>>;
263
- /** Admins only, self-host only: invites (or adds an existing account), refuses a duplicate row (spec 2026-09-25 members management §3). */
264
- invite(input: InviteInput): Promise<OpResult<InviteResult>>;
265
- /** Admins only, self-host only; never your own row, never the last admin. */
266
- setRole(userId: string, role: MemberRole): Promise<OpResult<MemberRecord>>;
267
- /** Admins only, self-host only; the same two guards. The Auth account stays. */
268
- remove(userId: string): Promise<OpResult<null>>;
269
263
  };
270
264
  /** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
271
265
  redirects: {
@@ -1,6 +1,6 @@
1
1
  import type { PresenceConfig } from "../env.ts";
2
2
  import type { AdminRegistry } from "./serialize.ts";
3
- import type { SignInConfig } from "./auth-config.ts";
3
+ import type { SignInConfig } from "./session-flow.ts";
4
4
  export type AdminPageData = {
5
5
  registry: AdminRegistry;
6
6
  /** Where the browser sends a person to sign in and the dashboard it links to: public values only. */
@@ -1,5 +1,11 @@
1
1
  import type { MemberRole } from "../permissions.ts";
2
- import type { SignInConfig } from "./auth-config.ts";
2
+ /** Where the admin sends a person to sign in, and the dashboard its account menu and Members page link to (spec
3
+ * 2026-10-16 sign-in and the dashboard §1, §6): `GET /v1/auth` answers it from the Worker's env; the handler and the
4
+ * page data carry it. Public values only. */
5
+ export type SignInConfig = {
6
+ signInUrl: string;
7
+ consoleUrl: string;
8
+ };
3
9
  export declare const STORAGE_KEY: (space: string) => string;
4
10
  /** The PKCE verifier, in this tab's `sessionStorage` only: the code is good for the browser tab that asked for it. */
5
11
  export declare const VERIFIER_KEY = "smoodly-verifier";
@@ -26,10 +32,26 @@ export declare function readSession(local: Storage, space: string): StoredSessio
26
32
  export declare function signInAddress(config: SignInConfig, space: string, returnUrl: string, stores: Pick<SessionStores, "tab">): Promise<string>;
27
33
  /** The one-time code the sign-in page put in the fragment, or null. */
28
34
  export declare function codeFrom(hash: string): string | null;
29
- /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. */
35
+ /** What the admin shows when the service cannot be reached at sign-in: a refusal with a way back, never a blank screen. */
36
+ export declare const UNREACHABLE = "smoodly: couldn't reach Smoodly to sign you in \u2014 check the connection and sign in again.";
37
+ /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. A service
38
+ * that cannot be reached is a refusal too (UNREACHABLE), so the shell shows it rather than waiting forever. */
30
39
  export declare function exchange(api: string, space: string, code: string, stores: SessionStores, doFetch?: typeof fetch): Promise<StoredSession | {
31
40
  refused: string;
32
41
  }>;
42
+ /** The load-time step with no code in the fragment: the stored session adopted as it is while its pass has more than
43
+ * RENEW_BEFORE_MS left, else renewed; a renewal the service refuses sends the person to sign in; one that cannot reach
44
+ * the service keeps a pass that is still valid (the next timer tries again) and refuses with UNREACHABLE only once the
45
+ * pass has expired. No stored session: sign in. */
46
+ export declare function resume(api: string, space: string, stores: Pick<SessionStores, "local">, now: number, doFetch?: typeof fetch, lock?: TabLock): Promise<{
47
+ kind: "adopt";
48
+ session: StoredSession;
49
+ } | {
50
+ kind: "sign_in";
51
+ } | {
52
+ kind: "refused";
53
+ message: string;
54
+ }>;
33
55
  /** Runs `fn` while holding a lock no other tab of this origin holds (Web Locks); with no Web Locks (an insecure
34
56
  * context), at once. */
35
57
  export type TabLock = <T>(name: string, fn: () => Promise<T>) => Promise<T>;
@@ -38,18 +38,45 @@ async function post(doFetch, url, body) {
38
38
  const json = (await res.json().catch(() => null));
39
39
  return { status: res.status, data: json?.ok ? json.data : null, message: typeof json?.message === "string" ? json.message : null };
40
40
  }
41
- /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. */
41
+ /** What the admin shows when the service cannot be reached at sign-in: a refusal with a way back, never a blank screen. */
42
+ export const UNREACHABLE = "smoodly: couldn't reach Smoodly to sign you in — check the connection and sign in again.";
43
+ /** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. A service
44
+ * that cannot be reached is a refusal too (UNREACHABLE), so the shell shows it rather than waiting forever. */
42
45
  export async function exchange(api, space, code, stores, doFetch = fetch) {
43
46
  const verifier = stores.tab.getItem(VERIFIER_KEY);
44
47
  stores.tab.removeItem(VERIFIER_KEY);
45
48
  if (!verifier)
46
49
  return { refused: "smoodly: this sign-in was started in another tab or has already been used — sign in again." };
47
- const answer = await post(doFetch, sessionUrl(api, space, "session"), { code, verifier });
50
+ let answer;
51
+ try {
52
+ answer = await post(doFetch, sessionUrl(api, space, "session"), { code, verifier });
53
+ }
54
+ catch {
55
+ return { refused: UNREACHABLE };
56
+ }
48
57
  if (answer.status !== 200 || !isSession(answer.data))
49
58
  return { refused: answer.message ?? `smoodly: the sign-in was refused (${answer.status}).` };
50
59
  writeSession(stores.local, space, answer.data);
51
60
  return answer.data;
52
61
  }
62
+ /** The load-time step with no code in the fragment: the stored session adopted as it is while its pass has more than
63
+ * RENEW_BEFORE_MS left, else renewed; a renewal the service refuses sends the person to sign in; one that cannot reach
64
+ * the service keeps a pass that is still valid (the next timer tries again) and refuses with UNREACHABLE only once the
65
+ * pass has expired. No stored session: sign in. */
66
+ export async function resume(api, space, stores, now, doFetch = fetch, lock = tabLock) {
67
+ const stored = readSession(stores.local, space);
68
+ if (!stored)
69
+ return { kind: "sign_in" };
70
+ if (stored.expiresAt - now > RENEW_BEFORE_MS)
71
+ return { kind: "adopt", session: stored };
72
+ try {
73
+ const renewed = await renew(api, space, stores, stored.refresh, doFetch, lock);
74
+ return renewed ? { kind: "adopt", session: renewed } : { kind: "sign_in" };
75
+ }
76
+ catch {
77
+ return stored.expiresAt > now ? { kind: "adopt", session: stored } : { kind: "refused", message: UNREACHABLE };
78
+ }
79
+ }
53
80
  const tabLock = (name, fn) => {
54
81
  const locks = globalThis.navigator?.locks;
55
82
  return locks ? locks.request(name, fn) : fn();
@@ -1,6 +1,6 @@
1
1
  import type { AdminOpAction } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- import type { SignInConfig } from "../auth-config.ts";
3
+ import type { SignInConfig } from "../session-flow.ts";
4
4
  import type { PresenceConfig } from "../../env.ts";
5
5
  export declare function AdminApp({ registry, op, signIn, api, presenceConfig }: {
6
6
  registry: AdminRegistry;
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // packages/smoodly/src/admin/shell/IconRail.tsx
4
- // The admin's primary navigation: the design system's 72px IconRail
5
- // (design_handoff_smoodly_cms/components/navigation/IconRail), which
6
- // replaced the 200px SideNav so the visual editor gets the width. Ported
7
- // markup-for-markup; the handoff's click handlers are the shell's router.
4
+ // The admin's primary navigation: the design handoff's 72px IconRail (the
5
+ // cool-blue look's components/navigation/IconRail, in the tree until 2026-10-19;
6
+ // the shipped tokens are design_handoff_smoodly_neutral/), which replaced the
7
+ // 200px SideNav so the visual editor gets the width. Ported markup-for-markup;
8
+ // the handoff's click handlers are the shell's router.
8
9
  //
9
10
  // Items are registry-derived, never separately configured: Pages, then
10
11
  // Collections — one rail item whose flyout lists every collection — then
@@ -15,7 +15,7 @@ export type PresenceState = {
15
15
  status: PresenceStatus;
16
16
  others: PresenceEntry[];
17
17
  };
18
- /** The channel presence uses: once a Realtime channel's slice, now the service's socket (presence-socket.ts). */
18
+ /** The channel presence uses: the service's socket (presence-socket.ts). */
19
19
  export type PresenceChannel = {
20
20
  subscribe(onStatus: (status: string) => void): void;
21
21
  /** The session renewed (its new pass): reconnect with a fresh ticket. The space's object admits a live session of a
@@ -1,5 +1,4 @@
1
- import type { SignInConfig } from "../auth-config.ts";
2
- import { type StoredSession } from "../session-flow.ts";
1
+ import { type SignInConfig, type StoredSession } from "../session-flow.ts";
3
2
  export type SessionUser = StoredSession["user"];
4
3
  export type SessionState = {
5
4
  status: "loading";
@@ -7,7 +7,7 @@
7
7
  // straight back in while their smoodly.io session lives. Another tab's renewal or sign out is adopted from the
8
8
  // `storage` event. The pure steps are admin/session-flow.ts.
9
9
  import { useCallback, useEffect, useRef, useState } from "react";
10
- import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, signInAddress, STORAGE_KEY } from "../session-flow.js";
10
+ import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, resume, signInAddress, STORAGE_KEY } from "../session-flow.js";
11
11
  /** A renewal's timer fires up to this much later, so two tabs rarely post the same refresh at once. */
12
12
  const JITTER_MS = 30000;
13
13
  export function useSpaceSession({ apiUrl, spaceId, signIn }) {
@@ -69,16 +69,17 @@ export function useSpaceSession({ apiUrl, spaceId, signIn }) {
69
69
  adopt(got);
70
70
  return;
71
71
  }
72
- const stored = readSession(window.localStorage, spaceId);
73
- if (!stored) {
74
- await signInAgain();
72
+ // The stored session, as it is or renewed; a service that cannot be reached keeps a still-valid pass and
73
+ // refuses, with a way back, only once it has expired (session-flow.ts resume).
74
+ const step = await resume(apiUrl, spaceId, stores(), Date.now());
75
+ if (!live)
75
76
  return;
76
- }
77
- held.current = stored;
78
- if (stored.expiresAt - Date.now() > RENEW_BEFORE_MS)
79
- adopt(stored);
77
+ if (step.kind === "adopt")
78
+ adopt(step.session);
79
+ else if (step.kind === "refused")
80
+ setState({ status: "signed_out", reason: "refused", message: step.message });
80
81
  else
81
- await renewNow();
82
+ await signInAgain();
82
83
  })();
83
84
  const onStorage = (e) => {
84
85
  if (e.key !== STORAGE_KEY(spaceId))
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The design system's primitives, ported 1:1 from the handoff's
4
- // components/ set (design_handoff_smoodly_cms). Structure and dynamic
4
+ // components/ set (the cool-blue look, in the tree until 2026-10-19; the shipped
5
+ // tokens are design_handoff_smoodly_neutral/). Structure and dynamic
5
6
  // values live here; every static rule, and all hover/focus state, lives in
6
7
  // theme.tsx's stylesheet.
7
8
  import { Fragment, useEffect, useRef } from "react";
@@ -65,7 +65,7 @@ export function createSmoodlyApi(options) {
65
65
  return (await call("/target-paths", { kind: target.kind, id: target.id })).data;
66
66
  },
67
67
  async load(target, locale, path, o) {
68
- const draft = !!o?.draft || o?.version !== undefined;
68
+ const draft = !!o?.draft || !!o?.version;
69
69
  const r = await call("/load", { kind: target.kind, id: target.id, locale, path, draft: o?.draft ? "1" : undefined, version: o?.version });
70
70
  return withTags(r.data, r.tags, draft);
71
71
  },
package/dist/assemble.js CHANGED
@@ -49,7 +49,7 @@ export function checkEntryQuery(schema, collection, o) {
49
49
  if (OBJECT_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
50
50
  throw new Error(`smoodly: getEntries("${collection}"): "${key}" holds an object — filter on a scalar field instead.`);
51
51
  }
52
- // Any-of reads the text form of the whole field (PostgREST's `->>`), which for
52
+ // Any-of reads the text form of the whole field (an array as its JSON text), which for
53
53
  // an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
54
54
  if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
55
55
  throw new Error(`smoodly: getEntries("${collection}"): "${key}" is array-valued — any-of is for scalar fields; pass one value to match entries containing it.`);
@@ -17,7 +17,7 @@ export type Settled = {
17
17
  draft: boolean;
18
18
  /** The request is under the preview address, cookie or not. */
19
19
  preview: boolean;
20
- /** The shell rendered a page read from the database and gave it a lifetime. */
20
+ /** The shell rendered a page read from the service and gave it a lifetime. */
21
21
  cacheable: boolean;
22
22
  /** When the request began, in milliseconds. */
23
23
  started: number;
@@ -79,11 +79,16 @@ export default function smoodly(options = {}) {
79
79
  let staticMedia = new Map();
80
80
  /** astro.config's `i18n`, which the build holds the config's `routing` against. */
81
81
  let i18n;
82
- /** The project's API client at build, from its config file (the one `options.config` names) and the env the setup hook loaded. */
83
- const buildApi = async () => {
84
- const loaded = options.config ? await loadConfigFile(join(root, options.config), root) : await loadConfig(root);
82
+ /** The project's config at build, from its config file (the one `options.config` names), loaded once and shared by
83
+ * every hook; and its API client, from the env the setup hook loaded, made once. Two steps, since the routing
84
+ * check wants the config before any env is needed. */
85
+ let builtConfig = null;
86
+ const buildConfig = () => (builtConfig ?? (builtConfig = options.config ? loadConfigFile(join(root, options.config), root) : loadConfig(root)));
87
+ let builtApi = null;
88
+ const buildApi = () => (builtApi ?? (builtApi = (async () => {
89
+ const loaded = await buildConfig();
85
90
  return { loaded, api: createSmoodlyApi({ ...apiEnv(process.env), config: loaded }) };
86
- };
91
+ })()));
87
92
  /** A build read that fails names the service's address and stops the build before any page renders (a static build
88
93
  * with the Worker down would otherwise fail page by page). */
89
94
  const fromApi = async (read) => {
@@ -115,7 +120,7 @@ export default function smoodly(options = {}) {
115
120
  injectRoute(route);
116
121
  addMiddleware({ entrypoint: here(`./middleware${ext}`), order: "pre" });
117
122
  if (output === "server" && !config.cache?.provider) {
118
- logger.warn("smoodly: no cache provider in astro.config — every page renders from the database. Add `cache: { provider: memoryCache() }` (from \"astro/config\").");
123
+ logger.warn("smoodly: no cache provider in astro.config — every page renders from the service. Add `cache: { provider: memoryCache() }` (from \"astro/config\").");
119
124
  }
120
125
  // astro.config is evaluated before Astro loads .env, so `site: process.env.SITE_URL` there sees only the
121
126
  // shell's value; the env files were read above, and SITE_URL from them sets `site` here. A value
@@ -149,7 +154,7 @@ export default function smoodly(options = {}) {
149
154
  // The schema push (spec 2026-10-16 the control plane §4) comes first, in either output: the space then holds the
150
155
  // registry this build renders with. A failed push never fails the build; the site runs against the schema the space has.
151
156
  "astro:build:start": async ({ logger }) => {
152
- const loaded = options.config ? await loadConfigFile(join(root, options.config), root) : await loadConfig(root);
157
+ const loaded = await buildConfig();
153
158
  // The runtime's startup check, at build in either output: a deploy that builds must not refuse every request.
154
159
  astroRouting(i18n, loaded.locales, loaded.routing);
155
160
  try {
@@ -19,7 +19,7 @@ export type { PageHead } from "./head.ts";
19
19
  export type { ViewFiles } from "./views-check.ts";
20
20
  import { type Shell, type SmoodlyLocals } from "./shell.ts";
21
21
  /** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
22
- * A page read from the database gets its lifetime here, before its view renders. The first call compares
22
+ * A page read from the service gets its lifetime here, before its view renders. The first call compares
23
23
  * the project's view files to the registry and names every disagreement once. */
24
24
  export declare function shell(pathname: string, search: string, state: SmoodlyLocals, views: ViewFiles, cache?: unknown): Promise<Shell>;
25
25
  /** getStaticPaths for the static page shell (spec 2026-10-09 static mode §3): every published address of the bound site. */
@@ -25,7 +25,7 @@ import { missingViews } from "./views-check.js";
25
25
  import { resolveShell } from "./shell.js";
26
26
  let viewsChecked = false;
27
27
  /** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
28
- * A page read from the database gets its lifetime here, before its view renders. The first call compares
28
+ * A page read from the service gets its lifetime here, before its view renders. The first call compares
29
29
  * the project's view files to the registry and names every disagreement once. */
30
30
  export async function shell(pathname, search, state, views, cache) {
31
31
  if (!viewsChecked) {
@@ -14,7 +14,7 @@ export type SmoodlyLocals = {
14
14
  editor: boolean;
15
15
  /** History's stored version, inside a preview only. */
16
16
  version?: string;
17
- /** Set by the shell when it rendered a page read from the database: the response may be cached under its tags. */
17
+ /** Set by the shell when it rendered a page read from the service: the response may be cached under its tags. */
18
18
  cacheable: boolean;
19
19
  /** Set by the shell when it rendered a page at all: in a preview, only that response is a canvas. */
20
20
  shell: boolean;
@@ -52,7 +52,7 @@ export async function resolveShell(deps, pathname, search, state, views) {
52
52
  console.warn(`smoodly: no src/page-types/${hit.template}.astro — the record at ${hit.path} is a 404.`);
53
53
  return NOT_FOUND;
54
54
  }
55
- // Only a page read from the database has a tag to expire it by.
55
+ // Only a page read from the service has a tag to expire it by.
56
56
  state.cacheable = !hit.record.id.startsWith("code:") && !state.draft;
57
57
  state.shell = true;
58
58
  const zoneNames = Object.keys(schema.zones ?? {});
@@ -22,6 +22,7 @@ export declare class SmoodlyContentError extends Error {
22
22
  /** What content() writes through: the operations, the paths index's lookup, an upload. */
23
23
  export type ContentTransport = {
24
24
  op(call: AdminOpCall): Promise<OpResult<unknown>>;
25
+ /** The target at an address, as a draft read: what the script is writing, never an edge copy. */
25
26
  locate(locale: string, path: string): Promise<PathHit | null>;
26
27
  upload(bytes: Uint8Array, filename: string, mime: string | undefined, meta: UploadMeta): Promise<ImageValue>;
27
28
  };
@@ -45,7 +45,9 @@ export function apiTransport(options) {
45
45
  const json = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
46
46
  return {
47
47
  op: (call) => send("/op", json({ path: call.path, args: call.args })),
48
- locate: (locale, path) => must(send(`/locate?${new URLSearchParams({ locale, path })}`)),
48
+ // A draft read (DESIGN §1 Content API › Writes): the caller holds the secret key and asks about what it is writing,
49
+ // and a draft read is never stored at the edge, so a script sees its own create at once.
50
+ locate: (locale, path) => must(send(`/locate?${new URLSearchParams({ locale, path, draft: "1" })}`)),
49
51
  // The bytes as the body with their length, the rest in the query (spec 2026-10-18 media on R2 and Images §4).
50
52
  async upload(bytes, filename, mime, meta) {
51
53
  const type = mime ?? mimeOf(filename);
package/dist/css.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The custom-CSS box (spec 2026-09-14 §4): a per-section snippet the
2
2
  // wrapper nests under the section's own [data-smd-node] selector. CSS
3
3
  // can do two things beyond styling — pull a remote stylesheet and beacon
4
- // to a host — so those, the two legacy code hooks and the tag-closing
4
+ // to a host — so those, the two old code hooks (`expression()`, `behavior:`) and the tag-closing
5
5
  // sequence are refused at save and stripped at render, in that order.
6
6
  // The checks run on the snippet with its CSS escapes decoded, so
7
7
  // `\75 rl(` cannot spell `url(` past them; and the braces must balance,
@@ -58,8 +58,8 @@ export type Where = Record<string, WhereValue | WhereValue[]>;
58
58
  * adapters read the text form of the whole array, never its elements). */
59
59
  export declare const ARRAY_FIELD_TYPES: ReadonlySet<string>;
60
60
  /** The memory adapter's and the tests' reading of `where`: a scalar matches an
61
- * equal value or an array holding it (JSONB containment); an array is any-of,
62
- * compared as text like PostgREST's `->>`. */
61
+ * equal value or an array holding it (containment); an array is any-of,
62
+ * compared as text (textForm). */
63
63
  export declare function matchesWhere(fields: Record<string, unknown>, where: Where): boolean;
64
64
  export type ListOptions = {
65
65
  /** Equality / any-of on shared fields, executed by the store (spec 2026-09-11 §3). */
@@ -256,8 +256,8 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
256
256
  * directions (the SQLite list compiler sorts nulls last to match);
257
257
  * the creation tiebreak follows `direction` for non-manual orders.
258
258
  * Declared fields compare numerically when both values are numbers,
259
- * otherwise as strings via `localeCompare` — the same order Postgres
260
- * gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
261
- * follow-ups for the mixed-type and JSON-null edges.
259
+ * otherwise as strings via `localeCompare` — the order the SQLite store's
260
+ * `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
261
+ * follow-ups for the mixed-type, JSON-null and non-ASCII edges).
262
262
  */
263
263
  export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
@@ -15,12 +15,12 @@ import { slugOf, unsupportedLocale } from "./paths.js";
15
15
  * containment, and any-of on one is refused by the site layer (both
16
16
  * adapters read the text form of the whole array, never its elements). */
17
17
  export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list", "tags"]);
18
- /** PostgREST's `->>`: a scalar's text, an array's or object's JSON text (Postgres
19
- * spaces an object's text differently, but no scalar equals either form). */
18
+ /** A field's text form: a scalar's text, an array's or object's JSON text (no scalar
19
+ * equals either form), the same in every store. */
20
20
  const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
21
21
  /** The memory adapter's and the tests' reading of `where`: a scalar matches an
22
- * equal value or an array holding it (JSONB containment); an array is any-of,
23
- * compared as text like PostgREST's `->>`. */
22
+ * equal value or an array holding it (containment); an array is any-of,
23
+ * compared as text (textForm). */
24
24
  export function matchesWhere(fields, where) {
25
25
  return Object.entries(where).every(([key, want]) => {
26
26
  const have = fields[key];
@@ -134,9 +134,9 @@ export function entryTitleIn(record, locale, schema) {
134
134
  * directions (the SQLite list compiler sorts nulls last to match);
135
135
  * the creation tiebreak follows `direction` for non-manual orders.
136
136
  * Declared fields compare numerically when both values are numbers,
137
- * otherwise as strings via `localeCompare` — the same order Postgres
138
- * gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
139
- * follow-ups for the mixed-type and JSON-null edges.
137
+ * otherwise as strings via `localeCompare` — the order the SQLite store's
138
+ * `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
139
+ * follow-ups for the mixed-type, JSON-null and non-ASCII edges).
140
140
  */
141
141
  export function compareEntries(order, seq) {
142
142
  return (a, b) => {
@@ -1,4 +1,4 @@
1
- // One image markup for both frameworks (spec 2026-10-18 media on R2 and Images §7; plan (c) decision 5): what
1
+ // One image markup for both frameworks (spec 2026-10-18 media on R2 and Images §7): what
2
2
  // <SmoodlyImage> (React) and <Image> (Astro) render, decided here so both are only markup and a test holds them equal.
3
3
  // Plain HTML, no optimizer and no Smoodly JavaScript: a box, an <img> whose srcset lists the cut's widths, or a
4
4
  // <picture> with one <source> per crop the view places. Without a cut (locally, an SVG) the original is framed in CSS
package/dist/index.d.ts CHANGED
@@ -30,8 +30,8 @@ export { assetErrors, kindOf, extensionFor, objectPath, PAGE_SIZE as ASSET_PAGE_
30
30
  export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
31
31
  export { imageDimensions } from "./image-dimensions.ts";
32
32
  export type { ImageQuality } from "./media-cut.ts";
33
- export { sortMembers, memberErrors } from "./member-store.ts";
34
- export type { MemberStore, MemberRecord, InviteInput, InviteResult } from "./member-store.ts";
33
+ export { sortMembers } from "./member-store.ts";
34
+ export type { MemberStore, MemberRecord } from "./member-store.ts";
35
35
  export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.ts";
36
36
  export type { MemberRole, Capability } from "./permissions.ts";
37
37
  export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ export { defineConfig } from "./config.js";
21
21
  export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.js";
22
22
  export { assetErrors, kindOf, extensionFor, objectPath, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
23
23
  export { imageDimensions } from "./image-dimensions.js";
24
- export { sortMembers, memberErrors } from "./member-store.js";
24
+ export { sortMembers } from "./member-store.js";
25
25
  export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
26
26
  export { collectEntryRefs, collectTreeRefs } from "./refs.js";
27
27
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
@@ -6,7 +6,7 @@ export type PixelRect = {
6
6
  height: number;
7
7
  };
8
8
  /** The qualities a cut is made at: unsigned in the address like the width, so a list bounds what Images is asked to
9
- * make (spec 2026-10-18 image quality and max width §2). 75 matched Vercel's optimizer in step 6's speed round. */
9
+ * make (spec 2026-10-18 image quality and max width §2). 75 matched Vercel's optimizer in the 2026-10-18 speed round. */
10
10
  export declare const IMAGE_QUALITIES: readonly [50, 60, 75, 85, 95, 100];
11
11
  export type ImageQuality = (typeof IMAGE_QUALITIES)[number];
12
12
  export declare const DEFAULT_QUALITY: ImageQuality;
package/dist/media-cut.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** The widths a cut is served at, plus the cut's own width; a wider one is never served (upscaling sells nothing).
2
2
  * From 640 to 1080 they are Next's device sizes, so a phone gets what `next/image` gave it (a 412-pixel screen at 1.75
3
- * finds 750, not 960; step 6 plan (d)'s speed round); 1536 spares a 1366- or 1440-wide laptop the 1920 (spec 2026-10-18
3
+ * finds 750, not 960; the 2026-10-18 speed round); 1536 spares a 1366- or 1440-wide laptop the 1920 (spec 2026-10-18
4
4
  * image quality and max width §5). */
5
5
  const CUT_WIDTHS = [320, 640, 750, 828, 1080, 1280, 1536, 1920, 2560];
6
6
  /** The qualities a cut is made at: unsigned in the address like the width, so a list bounds what Images is asked to
7
- * make (spec 2026-10-18 image quality and max width §2). 75 matched Vercel's optimizer in step 6's speed round. */
7
+ * make (spec 2026-10-18 image quality and max width §2). 75 matched Vercel's optimizer in the 2026-10-18 speed round. */
8
8
  export const IMAGE_QUALITIES = [50, 60, 75, 85, 95, 100];
9
9
  export const DEFAULT_QUALITY = 75;
10
10
  export const isImageQuality = (n) => IMAGE_QUALITIES.includes(n);
package/dist/media.d.ts CHANGED
@@ -115,7 +115,7 @@ export declare const ASSET_COLLECTION = "asset";
115
115
  export declare const MEDIA_TYPES: ReadonlySet<string>;
116
116
  /** Cache lifetime of a media object, in seconds — a year. Objects are
117
117
  * immutable (no upsert; Replace picks a new id), so the CDN, the browser
118
- * and next/image may keep one for as long as they like. */
118
+ * may keep one for as long as they like. */
119
119
  export declare const MEDIA_CACHE_SECONDS = 31536000;
120
120
  /** The largest file a space may accept (spec 2026-10-18 media on R2 and Images §4): Cloudflare's request body limit on
121
121
  * Free and Pro, since every upload streams through the Worker; a pushed `media.maxSize` over it is refused. */
package/dist/media.js CHANGED
@@ -5,7 +5,7 @@ export const ASSET_COLLECTION = "asset";
5
5
  export const MEDIA_TYPES = new Set(["image", "video", "file"]);
6
6
  /** Cache lifetime of a media object, in seconds — a year. Objects are
7
7
  * immutable (no upsert; Replace picks a new id), so the CDN, the browser
8
- * and next/image may keep one for as long as they like. */
8
+ * may keep one for as long as they like. */
9
9
  export const MEDIA_CACHE_SECONDS = 31536000;
10
10
  /** The largest file a space may accept (spec 2026-10-18 media on R2 and Images §4): Cloudflare's request body limit on
11
11
  * Free and Pro, since every upload streams through the Worker; a pushed `media.maxSize` over it is refused. */
@@ -113,7 +113,7 @@ const namedCropsOf = (d) => d.crops && typeof d.crops === "object" && Object.key
113
113
  * `crop` saved before the field declared crops (under `aspect`) is the first crop's when its ratio is the first's
114
114
  * within 1%, else ignored. The first crop's cut is the image's. */
115
115
  /** A stored crop counts only at the ratio its field declares now, within 1%: a ratio changed in code makes it follow
116
- * the hotspot at the new one, so the box, the cut and the crop window agree (the final review of plan (c)). */
116
+ * the hotspot at the new one, so the box, the cut and the crop window agree. */
117
117
  const atRatio = (crop, ratio, imageAspect) => Math.abs(cropAspect(crop, imageAspect) / parseAspect(ratio) - 1) <= 0.01;
118
118
  /** A named crop's framed rectangle, or undefined when it follows the hotspot: its stored entry, or for the first crop a
119
119
  * `crop` saved under `aspect`, at the crop's ratio (spec §6; the resolver and the crop window agree). */
@@ -1,42 +1,13 @@
1
- import type { MemberRole } from "./permissions.ts";
2
1
  export type MemberRecord = {
3
2
  userId: string;
4
3
  email: string | null;
5
4
  role: string;
6
5
  };
7
- export type InviteInput = {
8
- email: string;
9
- role: MemberRole;
10
- redirectTo: string;
11
- };
12
- /** `invited` false = the email already had a confirmed account, so no mail was sent; the row was added. (A pending invitee is re-invited: same account, mail again, `invited` true.) */
13
- export type InviteResult = {
14
- member: MemberRecord;
15
- invited: boolean;
16
- };
17
6
  export interface MemberStore {
18
7
  /** Every member of the current space, by email then user id. */
19
8
  list(): Promise<MemberRecord[]>;
20
- /** The space's console URL when the control plane manages its members; null in self-host. */
21
- consoleUrl(): Promise<string | null>;
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
- 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>;
31
- setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
32
- /** Deletes the member row only; the Auth account stays. */
33
- remove(userId: string): Promise<void>;
34
9
  /** The whole list at once, as the management API sets it (spec 2026-10-16 the control plane §3). */
35
10
  replaceAll(members: MemberRecord[]): Promise<void>;
36
11
  }
37
- export declare const memberErrors: {
38
- notFound: (userId: string) => Error;
39
- managed: () => Error;
40
- };
41
12
  /** Email first (case-insensitive), rows without one last, ties by user id. */
42
13
  export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
@@ -1,7 +1,3 @@
1
- export const memberErrors = {
2
- notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
3
- managed: () => new Error("smoodly: members are managed at smoodly.io."),
4
- };
5
1
  /** Email first (case-insensitive), rows without one last, ties by user id. */
6
2
  export function sortMembers(rows) {
7
3
  return [...rows].sort((a, b) => {
package/dist/paths.d.ts CHANGED
@@ -102,7 +102,7 @@ export declare function collectionSegment(path: string | Record<string, string>
102
102
  * it is a non-empty string — absent, null, or `""` all mean "no URL".
103
103
  * There is no sibling-slug index: sibling uniqueness is the primary key
104
104
  * of `paths` plus the pre-check each store makes per locale before it
105
- * writes (spec 2026-09-06 §12), so Postgres and memory agree on which
105
+ * writes (spec 2026-09-06 §12), so every store agrees on which
106
106
  * entries can collide. */
107
107
  export declare const slugOf: (fields: Record<string, unknown>) => string | null;
108
108
  /** The entry's segment in one locale: its row's slug. An absent locale