@clawnify/app 0.1.1 → 0.2.0

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.
@@ -0,0 +1,94 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ declare const HOST_BRIDGE_VERSION: 1;
5
+ /** One row in the navigation. `icon` is a lucide icon name (kebab-case). */
6
+ interface AppNavItem {
7
+ /** Stable identifier the host hands back on `navigate`. */
8
+ id: string;
9
+ label: string;
10
+ /**
11
+ * In-app path this item opens. Optional: a state-driven view can omit it.
12
+ * When present the host uses it to restore the view on reload and to make
13
+ * the dashboard URL shareable.
14
+ */
15
+ href?: string;
16
+ /** lucide icon name, e.g. "layout-dashboard". Unknown names render nothing. */
17
+ icon?: string;
18
+ /** Live badge, e.g. items waiting. Omit for none. */
19
+ count?: number;
20
+ /**
21
+ * The app's home screen. Not listed as a row: standalone the brand row
22
+ * opens it, and in the dashboard the app's own header does. One per app.
23
+ */
24
+ home?: boolean;
25
+ }
26
+ interface AppNavGroup {
27
+ /** Eyebrow shown above the items. Omit for an unlabelled group. */
28
+ label?: string;
29
+ items: AppNavItem[];
30
+ }
31
+ type AppToHostMessage = {
32
+ source: "clawnify-app";
33
+ v: 1;
34
+ type: "ready";
35
+ } | {
36
+ source: "clawnify-app";
37
+ v: 1;
38
+ type: "nav";
39
+ title: string;
40
+ groups: AppNavGroup[];
41
+ active?: string;
42
+ } | {
43
+ source: "clawnify-app";
44
+ v: 1;
45
+ type: "location";
46
+ path: string;
47
+ };
48
+ type HostToAppMessage = {
49
+ source: "clawnify-host";
50
+ v: 1;
51
+ type: "hello";
52
+ } | {
53
+ source: "clawnify-host";
54
+ v: 1;
55
+ type: "navigate";
56
+ id: string;
57
+ };
58
+ /**
59
+ * Whether this page is running inside the Clawnify dashboard. Computed once at
60
+ * module load — the query parameter is only on the initial URL — and remembered
61
+ * for the tab so in-iframe full navigations keep the mode.
62
+ */
63
+ declare const embedded: boolean;
64
+ /**
65
+ * Tell the host where the app is, so the dashboard URL can restore this view
66
+ * on reload. Call it whenever your router's location changes; a no-op when
67
+ * standalone.
68
+ */
69
+ declare function reportLocation(path: string): void;
70
+ interface AppNavProps {
71
+ /** App name, shown in the brand row standalone and in the host's switcher. */
72
+ title: string;
73
+ /** Brand icon for the standalone brand row. Not sent to the host. */
74
+ icon?: ReactNode;
75
+ groups: AppNavGroup[];
76
+ /** `id` of the active item. */
77
+ active?: string;
78
+ /** Called with the item the user chose, from either renderer. */
79
+ onNavigate: (item: AppNavItem) => void;
80
+ /** Rendered at the bottom of the standalone sidebar (a footer action, say). */
81
+ children?: ReactNode;
82
+ }
83
+ /**
84
+ * The app's navigation: sidebar when standalone, host-fed when embedded.
85
+ *
86
+ * Standalone layout: the sidebar is a 260px column at ≥768px and a horizontal
87
+ * strip below that, so put it first inside a `flex flex-col md:flex-row`
88
+ * container. It paints with the app's own theme tokens (`--surface`,
89
+ * `--border`, `--primary`…) and falls back to the platform defaults when a
90
+ * token is missing.
91
+ */
92
+ declare function AppNav({ title, icon, groups, active, onNavigate, children }: AppNavProps): react.JSX.Element | null;
93
+
94
+ export { AppNav, type AppNavGroup, type AppNavItem, type AppNavProps, type AppToHostMessage, HOST_BRIDGE_VERSION, type HostToAppMessage, embedded, reportLocation };
@@ -0,0 +1,187 @@
1
+ // src/client/index.tsx
2
+ import {
3
+ useEffect,
4
+ useRef
5
+ } from "react";
6
+ import { DynamicIcon, iconNames } from "lucide-react/dynamic";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
+ var HOST_BRIDGE_VERSION = 1;
9
+ var HOST_PARAM = "clawnify_host";
10
+ var STORAGE_KEY = "clawnify_host";
11
+ var embedded = (() => {
12
+ if (typeof window === "undefined") return false;
13
+ if (window.parent === window) return false;
14
+ let flagged = false;
15
+ try {
16
+ flagged = new URLSearchParams(window.location.search).get(HOST_PARAM) === "1";
17
+ if (flagged) window.sessionStorage.setItem(STORAGE_KEY, "1");
18
+ else flagged = window.sessionStorage.getItem(STORAGE_KEY) === "1";
19
+ } catch {
20
+ }
21
+ return flagged;
22
+ })();
23
+ function post(message) {
24
+ if (!embedded) return;
25
+ window.parent.postMessage(message, "*");
26
+ }
27
+ function reportLocation(path) {
28
+ let clean = path;
29
+ try {
30
+ const u = new URL(path, "http://app.invalid");
31
+ u.searchParams.delete("token");
32
+ u.searchParams.delete(HOST_PARAM);
33
+ clean = u.pathname + u.search + u.hash;
34
+ } catch {
35
+ }
36
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "location", path: clean });
37
+ }
38
+ function AppNav({ title, icon, groups, active, onNavigate, children }) {
39
+ const onNavigateRef = useRef(onNavigate);
40
+ onNavigateRef.current = onNavigate;
41
+ const groupsRef = useRef(groups);
42
+ groupsRef.current = groups;
43
+ const payload = embedded ? JSON.stringify({ title, groups: sanitize(groups), active }) : "";
44
+ const lastSent = useRef("");
45
+ useEffect(() => {
46
+ if (!embedded || payload === lastSent.current) return;
47
+ lastSent.current = payload;
48
+ const { title: title2, groups: groups2, active: active2 } = JSON.parse(payload);
49
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "nav", title: title2, groups: groups2, active: active2 });
50
+ }, [payload]);
51
+ useEffect(() => {
52
+ if (!embedded) return;
53
+ function onMessage(e) {
54
+ if (e.source !== window.parent) return;
55
+ const d = e.data;
56
+ if (!d || d.source !== "clawnify-host" || d.v !== HOST_BRIDGE_VERSION) return;
57
+ if (d.type === "hello") {
58
+ lastSent.current = "";
59
+ const g = sanitize(groupsRef.current);
60
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "nav", title, groups: g, active });
61
+ } else if (d.type === "navigate" && typeof d.id === "string") {
62
+ const item = groupsRef.current.flatMap((g) => g.items).find((i) => i.id === d.id);
63
+ if (item) onNavigateRef.current(item);
64
+ }
65
+ }
66
+ window.addEventListener("message", onMessage);
67
+ post({ source: "clawnify-app", v: HOST_BRIDGE_VERSION, type: "ready" });
68
+ return () => window.removeEventListener("message", onMessage);
69
+ }, []);
70
+ if (embedded) return null;
71
+ const home = groups.flatMap((g) => g.items).find((i) => i.home);
72
+ const brand = /* @__PURE__ */ jsxs(Fragment, { children: [
73
+ icon,
74
+ /* @__PURE__ */ jsx("span", { children: title })
75
+ ] });
76
+ return /* @__PURE__ */ jsxs("aside", { className: "cn-nav", children: [
77
+ /* @__PURE__ */ jsx("style", { children: CSS }),
78
+ home ? /* @__PURE__ */ jsx(
79
+ "a",
80
+ {
81
+ href: home.href ?? "/",
82
+ className: "cn-nav-brand",
83
+ "data-active": home.id === active || void 0,
84
+ "aria-current": home.id === active ? "page" : void 0,
85
+ onClick: (e) => {
86
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
87
+ e.preventDefault();
88
+ onNavigate(home);
89
+ },
90
+ children: brand
91
+ }
92
+ ) : /* @__PURE__ */ jsx("div", { className: "cn-nav-brand", children: brand }),
93
+ /* @__PURE__ */ jsx("nav", { className: "cn-nav-groups", "aria-label": "Primary", children: groups.map((g, gi) => /* @__PURE__ */ jsxs("div", { className: "cn-nav-group", children: [
94
+ g.label && /* @__PURE__ */ jsx("p", { className: "cn-nav-eyebrow", children: g.label }),
95
+ g.items.filter((item) => !item.home).map((item) => /* @__PURE__ */ jsx(NavRow, { item, active: item.id === active, onNavigate }, item.id))
96
+ ] }, g.label ?? gi)) }),
97
+ children && /* @__PURE__ */ jsx("div", { className: "cn-nav-footer", children })
98
+ ] });
99
+ }
100
+ function NavRow({
101
+ item,
102
+ active,
103
+ onNavigate
104
+ }) {
105
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
106
+ item.icon && isIconName(item.icon) && /* @__PURE__ */ jsx(DynamicIcon, { name: item.icon, size: 15, "aria-hidden": true }),
107
+ /* @__PURE__ */ jsx("span", { className: "cn-nav-label", children: item.label }),
108
+ typeof item.count === "number" && /* @__PURE__ */ jsx("span", { className: "cn-nav-count", children: item.count })
109
+ ] });
110
+ if (item.href) {
111
+ return /* @__PURE__ */ jsx(
112
+ "a",
113
+ {
114
+ href: item.href,
115
+ className: "cn-nav-item",
116
+ "data-active": active || void 0,
117
+ "aria-current": active ? "page" : void 0,
118
+ onClick: (e) => {
119
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
120
+ e.preventDefault();
121
+ onNavigate(item);
122
+ },
123
+ children: body
124
+ }
125
+ );
126
+ }
127
+ return /* @__PURE__ */ jsx(
128
+ "button",
129
+ {
130
+ type: "button",
131
+ className: "cn-nav-item",
132
+ "data-active": active || void 0,
133
+ "aria-current": active ? "page" : void 0,
134
+ onClick: () => onNavigate(item),
135
+ children: body
136
+ }
137
+ );
138
+ }
139
+ var iconNameSet = new Set(iconNames);
140
+ function isIconName(name) {
141
+ return iconNameSet.has(name);
142
+ }
143
+ function sanitize(groups) {
144
+ return groups.slice(0, 20).map((g) => ({
145
+ ...g.label ? { label: String(g.label).slice(0, 40) } : {},
146
+ items: g.items.slice(0, 100).map((i) => ({
147
+ id: String(i.id),
148
+ label: String(i.label).slice(0, 60),
149
+ ...i.href ? { href: String(i.href) } : {},
150
+ ...i.icon ? { icon: String(i.icon) } : {},
151
+ ...typeof i.count === "number" && Number.isFinite(i.count) ? { count: i.count } : {},
152
+ ...i.home ? { home: true } : {}
153
+ }))
154
+ }));
155
+ }
156
+ var CSS = `
157
+ .cn-nav{width:16.25rem;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);color:var(--foreground,#1a202c)}
158
+ .cn-nav-brand{height:3.5rem;flex-shrink:0;display:flex;align-items:center;gap:.5rem;padding:0 1.25rem;border-bottom:1px solid var(--border,#e2e8f0);font-size:.875rem;font-weight:600;color:inherit;text-decoration:none}
159
+ a.cn-nav-brand:hover{background:var(--sunken,#f1f5f9)}
160
+ .cn-nav-brand>svg{color:var(--primary,#dd5164)}
161
+ .cn-nav-groups{flex:1;overflow-y:auto;padding:.75rem}
162
+ .cn-nav-group+.cn-nav-group{margin-top:1rem}
163
+ .cn-nav-eyebrow{margin:0 0 .25rem;padding:0 .625rem;font-size:.75rem;font-weight:500;color:var(--muted,#475569)}
164
+ .cn-nav-item{display:flex;align-items:center;gap:.5rem;width:100%;margin:0 0 .125rem;padding:.4375rem .625rem;border:0;border-radius:.375rem;background:none;color:inherit;font:inherit;font-size:.875rem;text-align:left;text-decoration:none;cursor:pointer}
165
+ .cn-nav-item:hover{background:var(--sunken,#f1f5f9)}
166
+ .cn-nav-item[data-active]{background:color-mix(in srgb,var(--primary,#dd5164) 12%,transparent);color:var(--primary,#dd5164);font-weight:600}
167
+ .cn-nav-item:focus-visible{outline:2px solid var(--ring,#2563eb);outline-offset:-2px}
168
+ .cn-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
169
+ .cn-nav-count{font-size:.75rem;font-variant-numeric:tabular-nums;color:var(--muted,#475569)}
170
+ .cn-nav-item[data-active] .cn-nav-count{color:inherit}
171
+ .cn-nav-footer{padding:.75rem;border-top:1px solid var(--border,#e2e8f0)}
172
+ @media (max-width:767px){
173
+ .cn-nav{width:100%;flex-direction:row;border-right:0;border-bottom:1px solid var(--border,#e2e8f0)}
174
+ .cn-nav-brand,.cn-nav-eyebrow,.cn-nav-footer{display:none}
175
+ .cn-nav-groups{display:flex;gap:.25rem;padding:.5rem .75rem;overflow-x:auto;overflow-y:hidden}
176
+ .cn-nav-group{display:flex;gap:.25rem}
177
+ .cn-nav-group+.cn-nav-group{margin:0}
178
+ .cn-nav-item{width:auto;margin:0;white-space:nowrap}
179
+ }
180
+ `;
181
+ export {
182
+ AppNav,
183
+ HOST_BRIDGE_VERSION,
184
+ embedded,
185
+ reportLocation
186
+ };
187
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/index.tsx"],"sourcesContent":["/**\n * @clawnify/app/client — the host bridge for a Clawnify app's browser side.\n *\n * A Clawnify app is a full page on its own origin. Opened directly (local\n * `pnpm dev`, or `<slug>.apps.clawnify.com`) it renders its own sidebar.\n * Opened inside the Clawnify dashboard it sits in an iframe, and the dashboard\n * wants to render that same navigation in ITS sidebar so the user sees one\n * nav, not two.\n *\n * `<AppNav>` is the one definition of the app's navigation that serves both:\n *\n * • standalone → it draws the app's sidebar (desktop rail, mobile strip)\n * • embedded → it draws nothing and posts the nav tree to the host over\n * `postMessage`; the host renders it and posts `navigate`\n * back when the user clicks.\n *\n * Nothing about the app changes between the two: same router, same screens,\n * same data. Only who paints the list of links.\n *\n * import { AppNav, reportLocation } from \"@clawnify/app/client\";\n *\n * <AppNav\n * title=\"OpenDialer\"\n * icon={<Phone size={16} />}\n * groups={[{ label: \"Work\", items: [\n * { id: \"dashboard\", label: \"Dashboard\", href: \"/\", home: true },\n * { id: \"leads\", label: \"Leads\", href: \"/leads\", icon: \"users\", count: 12 },\n * ]}]}\n * active=\"leads\"\n * onNavigate={(item) => navigate(item.href!)}\n * />\n *\n * Re-render with a new `groups` / `active` and the host sidebar updates in\n * the same tick — counts are just a field, so \"Leads 12\" becomes \"Leads 11\"\n * the moment your own state does. No polling, no endpoint.\n *\n * Embedding is detected from the `clawnify_host=1` query parameter the\n * dashboard appends to the iframe URL, remembered in sessionStorage so a\n * full-page navigation inside the iframe stays in embedded mode. A page that\n * is iframed by anything else, or opened directly, is \"standalone\".\n *\n * Protocol (versioned, additive — `nav` and `location` are the first\n * message types; more host surfaces can ride the same channel later):\n *\n * app → host { source: \"clawnify-app\", v: 1, type: \"ready\" }\n * { source: \"clawnify-app\", v: 1, type: \"nav\", title, groups, active? }\n * { source: \"clawnify-app\", v: 1, type: \"location\", path }\n * host → app { source: \"clawnify-host\", v: 1, type: \"hello\" }\n * { source: \"clawnify-host\", v: 1, type: \"navigate\", id }\n */\nimport {\n useEffect,\n useRef,\n type MouseEvent,\n type ReactNode,\n} from \"react\";\nimport { DynamicIcon, iconNames } from \"lucide-react/dynamic\";\n\nexport const HOST_BRIDGE_VERSION = 1 as const;\n\n/** One row in the navigation. `icon` is a lucide icon name (kebab-case). */\nexport interface AppNavItem {\n /** Stable identifier the host hands back on `navigate`. */\n id: string;\n label: string;\n /**\n * In-app path this item opens. Optional: a state-driven view can omit it.\n * When present the host uses it to restore the view on reload and to make\n * the dashboard URL shareable.\n */\n href?: string;\n /** lucide icon name, e.g. \"layout-dashboard\". Unknown names render nothing. */\n icon?: string;\n /** Live badge, e.g. items waiting. Omit for none. */\n count?: number;\n /**\n * The app's home screen. Not listed as a row: standalone the brand row\n * opens it, and in the dashboard the app's own header does. One per app.\n */\n home?: boolean;\n}\n\nexport interface AppNavGroup {\n /** Eyebrow shown above the items. Omit for an unlabelled group. */\n label?: string;\n items: AppNavItem[];\n}\n\nexport type AppToHostMessage =\n | { source: \"clawnify-app\"; v: 1; type: \"ready\" }\n | {\n source: \"clawnify-app\";\n v: 1;\n type: \"nav\";\n title: string;\n groups: AppNavGroup[];\n active?: string;\n }\n | { source: \"clawnify-app\"; v: 1; type: \"location\"; path: string };\n\nexport type HostToAppMessage =\n | { source: \"clawnify-host\"; v: 1; type: \"hello\" }\n | { source: \"clawnify-host\"; v: 1; type: \"navigate\"; id: string };\n\nconst HOST_PARAM = \"clawnify_host\";\nconst STORAGE_KEY = \"clawnify_host\";\n\n/**\n * Whether this page is running inside the Clawnify dashboard. Computed once at\n * module load — the query parameter is only on the initial URL — and remembered\n * for the tab so in-iframe full navigations keep the mode.\n */\nexport const embedded: boolean = (() => {\n if (typeof window === \"undefined\") return false;\n if (window.parent === window) return false;\n let flagged = false;\n try {\n flagged = new URLSearchParams(window.location.search).get(HOST_PARAM) === \"1\";\n if (flagged) window.sessionStorage.setItem(STORAGE_KEY, \"1\");\n else flagged = window.sessionStorage.getItem(STORAGE_KEY) === \"1\";\n } catch {\n // Storage can throw in a partitioned or locked-down iframe; the query\n // parameter alone still decides for this document.\n }\n return flagged;\n})();\n\nfunction post(message: AppToHostMessage) {\n if (!embedded) return;\n // The dashboard may live on app.clawnify.com or a white-label domain, so the\n // app cannot pin the target origin. The payload is navigation labels and a\n // path — nothing secret — and the host verifies the sender is its own iframe.\n window.parent.postMessage(message, \"*\");\n}\n\n/**\n * Tell the host where the app is, so the dashboard URL can restore this view\n * on reload. Call it whenever your router's location changes; a no-op when\n * standalone.\n */\nexport function reportLocation(path: string) {\n // The initial URL carries the dashboard's own parameters (the app token\n // and the host flag). They are not part of the app's location and must\n // never land in a URL the dashboard writes, so strip them here as well.\n let clean = path;\n try {\n const u = new URL(path, \"http://app.invalid\");\n u.searchParams.delete(\"token\");\n u.searchParams.delete(HOST_PARAM);\n clean = u.pathname + u.search + u.hash;\n } catch {\n // Not a path we can parse: send as-is, the host validates its shape.\n }\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"location\", path: clean });\n}\n\nexport interface AppNavProps {\n /** App name, shown in the brand row standalone and in the host's switcher. */\n title: string;\n /** Brand icon for the standalone brand row. Not sent to the host. */\n icon?: ReactNode;\n groups: AppNavGroup[];\n /** `id` of the active item. */\n active?: string;\n /** Called with the item the user chose, from either renderer. */\n onNavigate: (item: AppNavItem) => void;\n /** Rendered at the bottom of the standalone sidebar (a footer action, say). */\n children?: ReactNode;\n}\n\n/**\n * The app's navigation: sidebar when standalone, host-fed when embedded.\n *\n * Standalone layout: the sidebar is a 260px column at ≥768px and a horizontal\n * strip below that, so put it first inside a `flex flex-col md:flex-row`\n * container. It paints with the app's own theme tokens (`--surface`,\n * `--border`, `--primary`…) and falls back to the platform defaults when a\n * token is missing.\n */\nexport function AppNav({ title, icon, groups, active, onNavigate, children }: AppNavProps) {\n // Always the latest callback, so the host listener never calls a stale one.\n const onNavigateRef = useRef(onNavigate);\n onNavigateRef.current = onNavigate;\n const groupsRef = useRef(groups);\n groupsRef.current = groups;\n\n // Embedded: publish the tree whenever it changes. Serialising is the cheap\n // way to dedupe — apps build `groups` inline every render.\n const payload = embedded\n ? JSON.stringify({ title, groups: sanitize(groups), active })\n : \"\";\n const lastSent = useRef(\"\");\n useEffect(() => {\n if (!embedded || payload === lastSent.current) return;\n lastSent.current = payload;\n const { title, groups, active } = JSON.parse(payload) as {\n title: string;\n groups: AppNavGroup[];\n active?: string;\n };\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"nav\", title, groups, active });\n }, [payload]);\n\n // Embedded: handshake + listen for the host's clicks.\n useEffect(() => {\n if (!embedded) return;\n function onMessage(e: MessageEvent) {\n if (e.source !== window.parent) return;\n const d = e.data as HostToAppMessage | undefined;\n if (!d || d.source !== \"clawnify-host\" || d.v !== HOST_BRIDGE_VERSION) return;\n if (d.type === \"hello\") {\n // Host (re)mounted its listener after we first published: resend.\n lastSent.current = \"\";\n const g = sanitize(groupsRef.current);\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"nav\", title, groups: g, active });\n } else if (d.type === \"navigate\" && typeof d.id === \"string\") {\n const item = groupsRef.current.flatMap((g) => g.items).find((i) => i.id === d.id);\n if (item) onNavigateRef.current(item);\n }\n }\n window.addEventListener(\"message\", onMessage);\n post({ source: \"clawnify-app\", v: HOST_BRIDGE_VERSION, type: \"ready\" });\n return () => window.removeEventListener(\"message\", onMessage);\n // title/active are read from the closure only on `hello`; the publish\n // effect above covers their normal updates.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n if (embedded) return null;\n\n const home = groups.flatMap((g) => g.items).find((i) => i.home);\n const brand = (\n <>\n {icon}\n <span>{title}</span>\n </>\n );\n\n return (\n <aside className=\"cn-nav\">\n <style>{CSS}</style>\n {home ? (\n <a\n href={home.href ?? \"/\"}\n className=\"cn-nav-brand\"\n data-active={home.id === active || undefined}\n aria-current={home.id === active ? \"page\" : undefined}\n onClick={(e: MouseEvent) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(home);\n }}\n >\n {brand}\n </a>\n ) : (\n <div className=\"cn-nav-brand\">{brand}</div>\n )}\n <nav className=\"cn-nav-groups\" aria-label=\"Primary\">\n {groups.map((g, gi) => (\n <div className=\"cn-nav-group\" key={g.label ?? gi}>\n {g.label && <p className=\"cn-nav-eyebrow\">{g.label}</p>}\n {g.items\n .filter((item) => !item.home)\n .map((item) => (\n <NavRow key={item.id} item={item} active={item.id === active} onNavigate={onNavigate} />\n ))}\n </div>\n ))}\n </nav>\n {children && <div className=\"cn-nav-footer\">{children}</div>}\n </aside>\n );\n}\n\nfunction NavRow({\n item,\n active,\n onNavigate,\n}: {\n item: AppNavItem;\n active: boolean;\n onNavigate: (item: AppNavItem) => void;\n}) {\n const body = (\n <>\n {item.icon && isIconName(item.icon) && <DynamicIcon name={item.icon} size={15} aria-hidden />}\n <span className=\"cn-nav-label\">{item.label}</span>\n {typeof item.count === \"number\" && <span className=\"cn-nav-count\">{item.count}</span>}\n </>\n );\n // A real link when there is an href — middle-click and copy-link keep\n // working — but the click itself goes through the app's router.\n if (item.href) {\n return (\n <a\n href={item.href}\n className=\"cn-nav-item\"\n data-active={active || undefined}\n aria-current={active ? \"page\" : undefined}\n onClick={(e: MouseEvent) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(item);\n }}\n >\n {body}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n className=\"cn-nav-item\"\n data-active={active || undefined}\n aria-current={active ? \"page\" : undefined}\n onClick={() => onNavigate(item)}\n >\n {body}\n </button>\n );\n}\n\nconst iconNameSet = new Set<string>(iconNames);\nfunction isIconName(name: string): name is Parameters<typeof DynamicIcon>[0][\"name\"] {\n return iconNameSet.has(name);\n}\n\n/** Only the fields the protocol defines, with sane bounds, cross the bridge. */\nfunction sanitize(groups: AppNavGroup[]): AppNavGroup[] {\n return groups.slice(0, 20).map((g) => ({\n ...(g.label ? { label: String(g.label).slice(0, 40) } : {}),\n items: g.items.slice(0, 100).map((i) => ({\n id: String(i.id),\n label: String(i.label).slice(0, 60),\n ...(i.href ? { href: String(i.href) } : {}),\n ...(i.icon ? { icon: String(i.icon) } : {}),\n ...(typeof i.count === \"number\" && Number.isFinite(i.count) ? { count: i.count } : {}),\n ...(i.home ? { home: true } : {}),\n })),\n }));\n}\n\n// Scoped by class prefix; tokens follow apps/DESIGN.md with its light defaults\n// as fallbacks, so an app with a partial theme still gets a readable sidebar.\nconst CSS = `\n.cn-nav{width:16.25rem;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);color:var(--foreground,#1a202c)}\n.cn-nav-brand{height:3.5rem;flex-shrink:0;display:flex;align-items:center;gap:.5rem;padding:0 1.25rem;border-bottom:1px solid var(--border,#e2e8f0);font-size:.875rem;font-weight:600;color:inherit;text-decoration:none}\na.cn-nav-brand:hover{background:var(--sunken,#f1f5f9)}\n.cn-nav-brand>svg{color:var(--primary,#dd5164)}\n.cn-nav-groups{flex:1;overflow-y:auto;padding:.75rem}\n.cn-nav-group+.cn-nav-group{margin-top:1rem}\n.cn-nav-eyebrow{margin:0 0 .25rem;padding:0 .625rem;font-size:.75rem;font-weight:500;color:var(--muted,#475569)}\n.cn-nav-item{display:flex;align-items:center;gap:.5rem;width:100%;margin:0 0 .125rem;padding:.4375rem .625rem;border:0;border-radius:.375rem;background:none;color:inherit;font:inherit;font-size:.875rem;text-align:left;text-decoration:none;cursor:pointer}\n.cn-nav-item:hover{background:var(--sunken,#f1f5f9)}\n.cn-nav-item[data-active]{background:color-mix(in srgb,var(--primary,#dd5164) 12%,transparent);color:var(--primary,#dd5164);font-weight:600}\n.cn-nav-item:focus-visible{outline:2px solid var(--ring,#2563eb);outline-offset:-2px}\n.cn-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.cn-nav-count{font-size:.75rem;font-variant-numeric:tabular-nums;color:var(--muted,#475569)}\n.cn-nav-item[data-active] .cn-nav-count{color:inherit}\n.cn-nav-footer{padding:.75rem;border-top:1px solid var(--border,#e2e8f0)}\n@media (max-width:767px){\n.cn-nav{width:100%;flex-direction:row;border-right:0;border-bottom:1px solid var(--border,#e2e8f0)}\n.cn-nav-brand,.cn-nav-eyebrow,.cn-nav-footer{display:none}\n.cn-nav-groups{display:flex;gap:.25rem;padding:.5rem .75rem;overflow-x:auto;overflow-y:hidden}\n.cn-nav-group{display:flex;gap:.25rem}\n.cn-nav-group+.cn-nav-group{margin:0}\n.cn-nav-item{width:auto;margin:0;white-space:nowrap}\n}\n`;\n"],"mappings":"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","names":["title","groups","active"]}
package/dist/index.d.ts CHANGED
@@ -46,7 +46,7 @@ interface RequestLike {
46
46
  * | `public` | an unauthenticated visitor on a declared public route |
47
47
  * | `bypass` | platform-internal fetch (build/screenshot) |
48
48
  * | `system` | the build pipeline |
49
- * | `app` | another app in the same org |
49
+ * | `app` | another app in the same org (or a headless org-token caller)|
50
50
  */
51
51
  type Caller = "user" | "api" | "agent" | "agent-browser" | "public" | "bypass" | "system" | "app";
52
52
  /** The signed-in person behind a request. */
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/identity.ts"],"sourcesContent":["/**\n * @clawnify/app — the standard Clawnify app skeleton in one call.\n *\n * `createApp(opts)` returns a fully-wired OpenAPIHono with every always-required,\n * cross-cutting concern pre-baked, so an app's server file is just imports +\n * routes + `export default app`:\n *\n * import { createApp, createRoute, z } from \"@clawnify/app\";\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(listContacts, handler);\n * export default app;\n *\n * What it bakes in — the boilerplate every app used to re-write in its server\n * file, and could forget:\n * • OpenAPIHono construction (typed to your Env).\n * • Database init — `initDB(c.env)` middleware on every request. @clawnify/db\n * auto-detects the D1 binding (production WfP) or the Storage capability\n * binding (Dynamic Workers preview), so handlers can query immediately.\n * • API discovery — `mountDiscovery` serves GET /api/openapi.json (the machine\n * contract) and GET /llms.txt (the lean agent index), generated from the\n * live routes so they can't drift.\n * • Identity — `user(c)` / `orgId(c)` / `caller(c)` read the platform-injected\n * `X-Clawnify-*` headers, so an app never hand-parses them (see identity.ts).\n *\n * The point: a new always-required concern is added HERE once and every app\n * inherits it on the next rebuild — instead of a line each agent must remember\n * to write (and a build gate to enforce) in every app.\n */\nimport { OpenAPIHono, mountDiscovery, type DiscoveryOptions } from \"@clawnify/routes\";\nimport { initDB } from \"@clawnify/db\";\nimport type { Env } from \"hono\";\n\n// One routing import for app authors: everything needed to declare routes and\n// build the app comes from @clawnify/app. These are re-exports of the same\n// @hono/zod-openapi primitives @clawnify/routes exposes — never reimplemented —\n// so route creation, discovery, and construction all live behind one import.\nexport {\n OpenAPIHono,\n createRoute,\n z,\n mountDiscovery,\n describeRoutes,\n} from \"@clawnify/routes\";\nexport type { DiscoveryOptions, DiscoverableApp } from \"@clawnify/routes\";\n\n// Identity of the caller, read from the platform-injected headers. Apps never\n// authenticate anyone themselves — see identity.ts.\nexport { user, orgId, caller } from \"./identity\";\nexport type { AppUser, Caller, RequestLike } from \"./identity\";\n\nexport interface CreateAppOptions extends DiscoveryOptions {\n /**\n * Wire the per-request database middleware (`initDB(c.env)` on `*`). Default\n * true — every Clawnify app has a D1 / Storage binding. Set false only for the\n * rare app with no database.\n */\n db?: boolean;\n /**\n * Mount GET /api/openapi.json + GET /llms.txt discovery. Default true. Set\n * false only if the app deliberately opts out of API discovery.\n */\n discovery?: boolean;\n}\n\n/**\n * Create a fully-wired Clawnify app.\n *\n * `E` is your Hono env — `{ Bindings: { DB: D1Database, ... } }` — pass it so\n * route handlers get a typed `c.env`. Everything after this call is your app's\n * own routes and business logic:\n *\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(route, handler);\n * export default app;\n */\nexport function createApp<E extends Env = Env>(\n opts: CreateAppOptions = {},\n): OpenAPIHono<E> {\n const app = new OpenAPIHono<E>();\n\n if (opts.db !== false) {\n app.use(\"*\", async (c, next) => {\n // @clawnify/db reads the DB (D1) or Storage binding off the env and caches\n // the Drizzle client for this request. Safe to call on every request.\n initDB(c.env as unknown as Parameters<typeof initDB>[0]);\n await next();\n });\n }\n\n if (opts.discovery !== false) {\n mountDiscovery(app, opts);\n }\n\n return app;\n}\n","/**\n * Identity — who is calling this app.\n *\n * The platform authenticates the caller at the perimeter (app-router, the\n * app-supervisor preview tier, or the /v1/apps/:id/proxy API) and injects a\n * *fresh* set of `X-Clawnify-*` headers. Any client-supplied `X-Clawnify-*` is\n * stripped first, so these headers cannot be spoofed and an app can trust them\n * directly.\n *\n * Apps therefore never authenticate anyone themselves: never read\n * `Authorization`, never parse a cookie, never verify a JWT. Read these\n * helpers instead.\n *\n * import { user, orgId, caller } from \"@clawnify/app\";\n *\n * app.openapi(route, (c) => {\n * const u = user(c); // null when the caller isn't a person\n * return c.json({ hello: u?.name ?? \"there\", org: orgId(c) });\n * });\n */\n\n/**\n * Minimal shape this module needs — Hono's `Context` satisfies it structurally,\n * so `user(c)` works in any handler regardless of the app's Env generics.\n */\nexport interface RequestLike {\n req: { header(name: string): string | undefined };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Only `user`, `api` and `agent` carry a human identity; the rest are machine\n * or anonymous callers, for which `user()` returns null.\n *\n * | Value | Who |\n * |-----------------|------------------------------------------------------------|\n * | `user` | a signed-in person in a browser |\n * | `api` | a person via the REST API / CLI / MCP (their own token) |\n * | `agent` | the org's OpenClaw agent calling the app's API |\n * | `agent-browser` | the agent driving a real browser on its VPS |\n * | `public` | an unauthenticated visitor on a declared public route |\n * | `bypass` | platform-internal fetch (build/screenshot) |\n * | `system` | the build pipeline |\n * | `app` | another app in the same org |\n */\nexport type Caller =\n | \"user\"\n | \"api\"\n | \"agent\"\n | \"agent-browser\"\n | \"public\"\n | \"bypass\"\n | \"system\"\n | \"app\";\n\nconst CALLERS = new Set<Caller>([\n \"user\",\n \"api\",\n \"agent\",\n \"agent-browser\",\n \"public\",\n \"bypass\",\n \"system\",\n \"app\",\n]);\n\n/** The signed-in person behind a request. */\nexport interface AppUser {\n /** Supabase user id — the stable key to store on rows (`created_by`). */\n id: string;\n email: string | null;\n /** Display name, e.g. \"Ada Lovelace\". Null if the account has no name set. */\n name: string | null;\n /** Profile picture URL (https). Null if the account has no avatar. */\n avatarUrl: string | null;\n /**\n * Best-effort split of `name` — a HEURISTIC, not platform data.\n *\n * The platform stores one `full_name`; there is no real given/family split\n * behind it. This takes the first whitespace-separated token, which is wrong\n * for many names (mononyms, multi-word given names, family-name-first\n * cultures). Use it for a friendly greeting (\"Hi, Ada\"), never as a stored\n * field or a record of someone's legal name — persist `name` instead.\n */\n firstName: string | null;\n /** Remainder of `name` after the first token. Same heuristic caveat. */\n lastName: string | null;\n}\n\nfunction read(c: RequestLike, name: string): string | null {\n const v = c.req.header(name);\n if (v === undefined) return null;\n const trimmed = v.trim();\n return trimmed === \"\" ? null : trimmed;\n}\n\nfunction splitName(name: string | null): Pick<AppUser, \"firstName\" | \"lastName\"> {\n if (!name) return { firstName: null, lastName: null };\n const parts = name.split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: null, lastName: null };\n if (parts.length === 1) return { firstName: parts[0], lastName: null };\n return { firstName: parts[0], lastName: parts.slice(1).join(\" \") };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Defaults to `\"public\"` — the least-privileged value — when the header is\n * missing or unrecognised, which happens when the app is reached outside the\n * platform perimeter (local `pnpm dev`). Gate privileged behaviour on an\n * explicit check (`caller(c) === \"user\"`), never on \"not public\".\n */\nexport function caller(c: RequestLike): Caller {\n const v = read(c, \"X-Clawnify-Caller\");\n return v && CALLERS.has(v as Caller) ? (v as Caller) : \"public\";\n}\n\n/**\n * The organization this request belongs to — the tenant key. Scope every query\n * by it.\n *\n * Present for `user`, `api`, `agent` and `app` callers. **Null** for\n * `agent-browser`, `public` and `bypass` — those reach the app through the\n * perimeter without an org-scoped identity — and null outside the platform.\n *\n * Treat null as \"no access\", never as a wildcard: a query filtered on a null\n * org must return nothing, not everything.\n */\nexport function orgId(c: RequestLike): string | null {\n return read(c, \"X-Clawnify-Org-Id\");\n}\n\n/**\n * The person behind this request, or `null` when the caller isn't one.\n *\n * Null for `agent-browser`, `public`, `bypass`, `system` and `app` — five of\n * the eight caller types — so always handle the null case rather than assuming\n * a user is present:\n *\n * const u = user(c);\n * const label = u ? u.name ?? u.email : \"Automated\";\n */\nexport function user(c: RequestLike): AppUser | null {\n const id = read(c, \"X-Clawnify-User-Id\");\n if (!id) return null;\n\n const name = read(c, \"X-Clawnify-User-Name\");\n return {\n id,\n email: read(c, \"X-Clawnify-User-Email\"),\n name,\n avatarUrl: read(c, \"X-Clawnify-User-Avatar\"),\n ...splitName(name),\n };\n}\n"],"mappings":";AA4BA,SAAS,aAAa,sBAA6C;AACnE,SAAS,cAAc;AAOvB;AAAA,EACE,eAAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAAC;AAAA,EACA;AAAA,OACK;;;ACcP,IAAM,UAAU,oBAAI,IAAY;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAyBD,SAAS,KAAK,GAAgB,MAA6B;AACzD,QAAM,IAAI,EAAE,IAAI,OAAO,IAAI;AAC3B,MAAI,MAAM,OAAW,QAAO;AAC5B,QAAM,UAAU,EAAE,KAAK;AACvB,SAAO,YAAY,KAAK,OAAO;AACjC;AAEA,SAAS,UAAU,MAA8D;AAC/E,MAAI,CAAC,KAAM,QAAO,EAAE,WAAW,MAAM,UAAU,KAAK;AACpD,QAAM,QAAQ,KAAK,MAAM,KAAK,EAAE,OAAO,OAAO;AAC9C,MAAI,MAAM,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,UAAU,KAAK;AACjE,MAAI,MAAM,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,CAAC,GAAG,UAAU,KAAK;AACrE,SAAO,EAAE,WAAW,MAAM,CAAC,GAAG,UAAU,MAAM,MAAM,CAAC,EAAE,KAAK,GAAG,EAAE;AACnE;AAUO,SAAS,OAAO,GAAwB;AAC7C,QAAM,IAAI,KAAK,GAAG,mBAAmB;AACrC,SAAO,KAAK,QAAQ,IAAI,CAAW,IAAK,IAAe;AACzD;AAaO,SAAS,MAAM,GAA+B;AACnD,SAAO,KAAK,GAAG,mBAAmB;AACpC;AAYO,SAAS,KAAK,GAAgC;AACnD,QAAM,KAAK,KAAK,GAAG,oBAAoB;AACvC,MAAI,CAAC,GAAI,QAAO;AAEhB,QAAM,OAAO,KAAK,GAAG,sBAAsB;AAC3C,SAAO;AAAA,IACL;AAAA,IACA,OAAO,KAAK,GAAG,uBAAuB;AAAA,IACtC;AAAA,IACA,WAAW,KAAK,GAAG,wBAAwB;AAAA,IAC3C,GAAG,UAAU,IAAI;AAAA,EACnB;AACF;;;ADhFO,SAAS,UACd,OAAyB,CAAC,GACV;AAChB,QAAM,MAAM,IAAI,YAAe;AAE/B,MAAI,KAAK,OAAO,OAAO;AACrB,QAAI,IAAI,KAAK,OAAO,GAAG,SAAS;AAG9B,aAAO,EAAE,GAA8C;AACvD,YAAM,KAAK;AAAA,IACb,CAAC;AAAA,EACH;AAEA,MAAI,KAAK,cAAc,OAAO;AAC5B,mBAAe,KAAK,IAAI;AAAA,EAC1B;AAEA,SAAO;AACT;","names":["OpenAPIHono","mountDiscovery"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/identity.ts"],"sourcesContent":["/**\n * @clawnify/app — the standard Clawnify app skeleton in one call.\n *\n * `createApp(opts)` returns a fully-wired OpenAPIHono with every always-required,\n * cross-cutting concern pre-baked, so an app's server file is just imports +\n * routes + `export default app`:\n *\n * import { createApp, createRoute, z } from \"@clawnify/app\";\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(listContacts, handler);\n * export default app;\n *\n * What it bakes in — the boilerplate every app used to re-write in its server\n * file, and could forget:\n * • OpenAPIHono construction (typed to your Env).\n * • Database init — `initDB(c.env)` middleware on every request. @clawnify/db\n * auto-detects the D1 binding (production WfP) or the Storage capability\n * binding (Dynamic Workers preview), so handlers can query immediately.\n * • API discovery — `mountDiscovery` serves GET /api/openapi.json (the machine\n * contract) and GET /llms.txt (the lean agent index), generated from the\n * live routes so they can't drift.\n * • Identity — `user(c)` / `orgId(c)` / `caller(c)` read the platform-injected\n * `X-Clawnify-*` headers, so an app never hand-parses them (see identity.ts).\n *\n * The point: a new always-required concern is added HERE once and every app\n * inherits it on the next rebuild — instead of a line each agent must remember\n * to write (and a build gate to enforce) in every app.\n */\nimport { OpenAPIHono, mountDiscovery, type DiscoveryOptions } from \"@clawnify/routes\";\nimport { initDB } from \"@clawnify/db\";\nimport type { Env } from \"hono\";\n\n// One routing import for app authors: everything needed to declare routes and\n// build the app comes from @clawnify/app. These are re-exports of the same\n// @hono/zod-openapi primitives @clawnify/routes exposes — never reimplemented —\n// so route creation, discovery, and construction all live behind one import.\nexport {\n OpenAPIHono,\n createRoute,\n z,\n mountDiscovery,\n describeRoutes,\n} from \"@clawnify/routes\";\nexport type { DiscoveryOptions, DiscoverableApp } from \"@clawnify/routes\";\n\n// Identity of the caller, read from the platform-injected headers. Apps never\n// authenticate anyone themselves — see identity.ts.\nexport { user, orgId, caller } from \"./identity\";\nexport type { AppUser, Caller, RequestLike } from \"./identity\";\n\nexport interface CreateAppOptions extends DiscoveryOptions {\n /**\n * Wire the per-request database middleware (`initDB(c.env)` on `*`). Default\n * true — every Clawnify app has a D1 / Storage binding. Set false only for the\n * rare app with no database.\n */\n db?: boolean;\n /**\n * Mount GET /api/openapi.json + GET /llms.txt discovery. Default true. Set\n * false only if the app deliberately opts out of API discovery.\n */\n discovery?: boolean;\n}\n\n/**\n * Create a fully-wired Clawnify app.\n *\n * `E` is your Hono env — `{ Bindings: { DB: D1Database, ... } }` — pass it so\n * route handlers get a typed `c.env`. Everything after this call is your app's\n * own routes and business logic:\n *\n * const app = createApp<Env>({ title: \"CRM App\", version: \"1.0.0\" });\n * app.openapi(route, handler);\n * export default app;\n */\nexport function createApp<E extends Env = Env>(\n opts: CreateAppOptions = {},\n): OpenAPIHono<E> {\n const app = new OpenAPIHono<E>();\n\n if (opts.db !== false) {\n app.use(\"*\", async (c, next) => {\n // @clawnify/db reads the DB (D1) or Storage binding off the env and caches\n // the Drizzle client for this request. Safe to call on every request.\n initDB(c.env as unknown as Parameters<typeof initDB>[0]);\n await next();\n });\n }\n\n if (opts.discovery !== false) {\n mountDiscovery(app, opts);\n }\n\n return app;\n}\n","/**\n * Identity — who is calling this app.\n *\n * The platform authenticates the caller at the perimeter (app-router, the\n * app-supervisor preview tier, or the /v1/apps/:id/proxy API) and injects a\n * *fresh* set of `X-Clawnify-*` headers. Any client-supplied `X-Clawnify-*` is\n * stripped first, so these headers cannot be spoofed and an app can trust them\n * directly.\n *\n * Apps therefore never authenticate anyone themselves: never read\n * `Authorization`, never parse a cookie, never verify a JWT. Read these\n * helpers instead.\n *\n * import { user, orgId, caller } from \"@clawnify/app\";\n *\n * app.openapi(route, (c) => {\n * const u = user(c); // null when the caller isn't a person\n * return c.json({ hello: u?.name ?? \"there\", org: orgId(c) });\n * });\n */\n\n/**\n * Minimal shape this module needs — Hono's `Context` satisfies it structurally,\n * so `user(c)` works in any handler regardless of the app's Env generics.\n */\nexport interface RequestLike {\n req: { header(name: string): string | undefined };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Only `user`, `api` and `agent` carry a human identity; the rest are machine\n * or anonymous callers, for which `user()` returns null.\n *\n * | Value | Who |\n * |-----------------|------------------------------------------------------------|\n * | `user` | a signed-in person in a browser |\n * | `api` | a person via the REST API / CLI / MCP (their own token) |\n * | `agent` | the org's OpenClaw agent calling the app's API |\n * | `agent-browser` | the agent driving a real browser on its VPS |\n * | `public` | an unauthenticated visitor on a declared public route |\n * | `bypass` | platform-internal fetch (build/screenshot) |\n * | `system` | the build pipeline |\n * | `app` | another app in the same org (or a headless org-token caller)|\n */\nexport type Caller =\n | \"user\"\n | \"api\"\n | \"agent\"\n | \"agent-browser\"\n | \"public\"\n | \"bypass\"\n | \"system\"\n | \"app\";\n\nconst CALLERS = new Set<Caller>([\n \"user\",\n \"api\",\n \"agent\",\n \"agent-browser\",\n \"public\",\n \"bypass\",\n \"system\",\n \"app\",\n]);\n\n/** The signed-in person behind a request. */\nexport interface AppUser {\n /** Supabase user id — the stable key to store on rows (`created_by`). */\n id: string;\n email: string | null;\n /** Display name, e.g. \"Ada Lovelace\". Null if the account has no name set. */\n name: string | null;\n /** Profile picture URL (https). Null if the account has no avatar. */\n avatarUrl: string | null;\n /**\n * Best-effort split of `name` — a HEURISTIC, not platform data.\n *\n * The platform stores one `full_name`; there is no real given/family split\n * behind it. This takes the first whitespace-separated token, which is wrong\n * for many names (mononyms, multi-word given names, family-name-first\n * cultures). Use it for a friendly greeting (\"Hi, Ada\"), never as a stored\n * field or a record of someone's legal name — persist `name` instead.\n */\n firstName: string | null;\n /** Remainder of `name` after the first token. Same heuristic caveat. */\n lastName: string | null;\n}\n\nfunction read(c: RequestLike, name: string): string | null {\n const v = c.req.header(name);\n if (v === undefined) return null;\n const trimmed = v.trim();\n return trimmed === \"\" ? null : trimmed;\n}\n\nfunction splitName(name: string | null): Pick<AppUser, \"firstName\" | \"lastName\"> {\n if (!name) return { firstName: null, lastName: null };\n const parts = name.split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: null, lastName: null };\n if (parts.length === 1) return { firstName: parts[0], lastName: null };\n return { firstName: parts[0], lastName: parts.slice(1).join(\" \") };\n}\n\n/**\n * What kind of principal is calling.\n *\n * Defaults to `\"public\"` — the least-privileged value — when the header is\n * missing or unrecognised, which happens when the app is reached outside the\n * platform perimeter (local `pnpm dev`). Gate privileged behaviour on an\n * explicit check (`caller(c) === \"user\"`), never on \"not public\".\n */\nexport function caller(c: RequestLike): Caller {\n const v = read(c, \"X-Clawnify-Caller\");\n return v && CALLERS.has(v as Caller) ? (v as Caller) : \"public\";\n}\n\n/**\n * The organization this request belongs to — the tenant key. Scope every query\n * by it.\n *\n * Present for `user`, `api`, `agent` and `app` callers. **Null** for\n * `agent-browser`, `public` and `bypass` — those reach the app through the\n * perimeter without an org-scoped identity — and null outside the platform.\n *\n * Treat null as \"no access\", never as a wildcard: a query filtered on a null\n * org must return nothing, not everything.\n */\nexport function orgId(c: RequestLike): string | null {\n return read(c, \"X-Clawnify-Org-Id\");\n}\n\n/**\n * The person behind this request, or `null` when the caller isn't one.\n *\n * Null for `agent-browser`, `public`, `bypass`, `system` and `app` — five of\n * the eight caller types — so always handle the null case rather than assuming\n * a user is present:\n *\n * const u = user(c);\n * const label = u ? u.name ?? u.email : \"Automated\";\n */\nexport function user(c: RequestLike): AppUser | null {\n const id = read(c, \"X-Clawnify-User-Id\");\n if (!id) return null;\n\n const name = read(c, \"X-Clawnify-User-Name\");\n return {\n id,\n email: read(c, \"X-Clawnify-User-Email\"),\n name,\n avatarUrl: read(c, \"X-Clawnify-User-Avatar\"),\n ...splitName(name),\n };\n}\n"],"mappings":";AA4BA,SAAS,aAAa,sBAA6C;AACnE,SAAS,cAAc;AAOvB;AAAA,EACE,eAAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAAC;AAAA,EACA;AAAA,OACK;;;ACcP,IAAM,UAAU,oBAAI,IAAY;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAyBD,SAAS,KAAK,GAAgB,MAA6B;AACzD,QAAM,IAAI,EAAE,IAAI,OAAO,IAAI;AAC3B,MAAI,MAAM,OAAW,QAAO;AAC5B,QAAM,UAAU,EAAE,KAAK;AACvB,SAAO,YAAY,KAAK,OAAO;AACjC;AAEA,SAAS,UAAU,MAA8D;AAC/E,MAAI,CAAC,KAAM,QAAO,EAAE,WAAW,MAAM,UAAU,KAAK;AACpD,QAAM,QAAQ,KAAK,MAAM,KAAK,EAAE,OAAO,OAAO;AAC9C,MAAI,MAAM,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,UAAU,KAAK;AACjE,MAAI,MAAM,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,CAAC,GAAG,UAAU,KAAK;AACrE,SAAO,EAAE,WAAW,MAAM,CAAC,GAAG,UAAU,MAAM,MAAM,CAAC,EAAE,KAAK,GAAG,EAAE;AACnE;AAUO,SAAS,OAAO,GAAwB;AAC7C,QAAM,IAAI,KAAK,GAAG,mBAAmB;AACrC,SAAO,KAAK,QAAQ,IAAI,CAAW,IAAK,IAAe;AACzD;AAaO,SAAS,MAAM,GAA+B;AACnD,SAAO,KAAK,GAAG,mBAAmB;AACpC;AAYO,SAAS,KAAK,GAAgC;AACnD,QAAM,KAAK,KAAK,GAAG,oBAAoB;AACvC,MAAI,CAAC,GAAI,QAAO;AAEhB,QAAM,OAAO,KAAK,GAAG,sBAAsB;AAC3C,SAAO;AAAA,IACL;AAAA,IACA,OAAO,KAAK,GAAG,uBAAuB;AAAA,IACtC;AAAA,IACA,WAAW,KAAK,GAAG,wBAAwB;AAAA,IAC3C,GAAG,UAAU,IAAI;AAAA,EACnB;AACF;;;ADhFO,SAAS,UACd,OAAyB,CAAC,GACV;AAChB,QAAM,MAAM,IAAI,YAAe;AAE/B,MAAI,KAAK,OAAO,OAAO;AACrB,QAAI,IAAI,KAAK,OAAO,GAAG,SAAS;AAG9B,aAAO,EAAE,GAA8C;AACvD,YAAM,KAAK;AAAA,IACb,CAAC;AAAA,EACH;AAEA,MAAI,KAAK,cAAc,OAAO;AAC5B,mBAAe,KAAK,IAAI;AAAA,EAC1B;AAEA,SAAO;AACT;","names":["OpenAPIHono","mountDiscovery"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawnify/app",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The standard Clawnify app skeleton in one call. createApp() returns a fully-wired OpenAPIHono with D1/Storage init + /api/openapi.json + /llms.txt discovery pre-baked, so an app is just imports + routes + export.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -12,26 +12,45 @@
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.js",
14
14
  "default": "./dist/index.js"
15
+ },
16
+ "./client": {
17
+ "types": "./dist/client/index.d.ts",
18
+ "import": "./dist/client/index.js",
19
+ "default": "./dist/client/index.js"
15
20
  }
16
21
  },
17
22
  "files": [
18
23
  "dist"
19
24
  ],
20
25
  "dependencies": {
21
- "@clawnify/routes": "^0.2.1",
22
- "@clawnify/db": "^0.4.1"
26
+ "@clawnify/db": "^0.4.1",
27
+ "@clawnify/routes": "^0.2.2"
23
28
  },
24
29
  "peerDependencies": {
25
30
  "@hono/zod-openapi": ">=0.18 <1",
26
- "hono": "*"
31
+ "hono": "*",
32
+ "react": ">=18",
33
+ "lucide-react": ">=0.400"
27
34
  },
28
35
  "devDependencies": {
29
36
  "@cloudflare/workers-types": "^4.20260405.1",
30
37
  "@hono/zod-openapi": "^0.18.0",
38
+ "@types/react": "^19.2.18",
31
39
  "hono": "^4.6.0",
40
+ "lucide-react": "^0.564.0",
41
+ "react": "^19.2.8",
42
+ "react-dom": "^19.2.8",
32
43
  "tsup": "^8.0.0",
33
44
  "typescript": "^5.7.0"
34
45
  },
46
+ "peerDependenciesMeta": {
47
+ "react": {
48
+ "optional": true
49
+ },
50
+ "lucide-react": {
51
+ "optional": true
52
+ }
53
+ },
35
54
  "scripts": {
36
55
  "build": "tsup",
37
56
  "dev": "tsup --watch"