restaurant-kit 0.1.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.
Files changed (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +488 -0
  3. package/dist/RestaurantSiteRoutes.js +23 -0
  4. package/dist/admin/AdminActionMenu.js +29 -0
  5. package/dist/admin/AdminAuthPanel.js +53 -0
  6. package/dist/admin/AdminBackupsDialog.js +54 -0
  7. package/dist/admin/AdminDraftProvider.js +10 -0
  8. package/dist/admin/AdminGroupsPanel.js +68 -0
  9. package/dist/admin/AdminItemDrawer.js +264 -0
  10. package/dist/admin/AdminMenuCard.js +63 -0
  11. package/dist/admin/AdminMenuEditor.module.css.js +209 -0
  12. package/dist/admin/AdminMenuGroupTabs.js +43 -0
  13. package/dist/admin/AdminMenuIconPicker.js +39 -0
  14. package/dist/admin/AdminRoutes.js +13 -0
  15. package/dist/admin/AdminSectionEditor.js +104 -0
  16. package/dist/admin/AdminTestimonialsPanel.js +214 -0
  17. package/dist/admin/AdminTestimonialsPanel.module.css.js +29 -0
  18. package/dist/admin/adminDraftContext.js +5 -0
  19. package/dist/admin/adminEditorUtils.js +47 -0
  20. package/dist/admin/adminMenuCommands.js +83 -0
  21. package/dist/admin/useAdminDraft.js +14 -0
  22. package/dist/admin/useAdminMenuDraft.js +226 -0
  23. package/dist/cart/CartDrawer.js +87 -0
  24. package/dist/cart/CartDrawer.module.css.js +47 -0
  25. package/dist/cart/CartProvider.js +31 -0
  26. package/dist/cart/cartContext.js +5 -0
  27. package/dist/cart/cartOrder.js +34 -0
  28. package/dist/cart/cartReducer.js +57 -0
  29. package/dist/cart/useCart.js +10 -0
  30. package/dist/components/Brand.js +16 -0
  31. package/dist/components/Brand.module.css.js +15 -0
  32. package/dist/components/Footer.js +19 -0
  33. package/dist/components/Footer.module.css.js +11 -0
  34. package/dist/components/Header.js +34 -0
  35. package/dist/components/Header.module.css.js +19 -0
  36. package/dist/components/Hero.js +23 -0
  37. package/dist/components/Hero.module.css.js +15 -0
  38. package/dist/components/InstagramIcon.js +27 -0
  39. package/dist/components/LocationsSection.js +34 -0
  40. package/dist/components/LocationsSection.module.css.js +17 -0
  41. package/dist/components/Marquee.js +10 -0
  42. package/dist/components/Marquee.module.css.js +9 -0
  43. package/dist/components/OrderIcon.js +11 -0
  44. package/dist/components/RatingSection.js +32 -0
  45. package/dist/components/RatingSection.module.css.js +19 -0
  46. package/dist/components/TestimonialsSection.js +73 -0
  47. package/dist/components/TestimonialsSection.module.css.js +37 -0
  48. package/dist/components/WhatsAppIcon.js +13 -0
  49. package/dist/config/RestaurantKitProvider.js +26 -0
  50. package/dist/config/siteConfigContext.js +23 -0
  51. package/dist/config/useOrdering.js +12 -0
  52. package/dist/index.js +92 -0
  53. package/dist/menu/localMenuApi.js +43 -0
  54. package/dist/menu/menuApi.js +121 -0
  55. package/dist/menu/menuDraftStorage.js +35 -0
  56. package/dist/menu/menuIcons.js +24 -0
  57. package/dist/menu/menuMutations.js +99 -0
  58. package/dist/menu/menuOrdering.js +94 -0
  59. package/dist/menu/menuSchema.js +4 -0
  60. package/dist/menu/priceFormat.js +39 -0
  61. package/dist/menu/useMenuData.js +38 -0
  62. package/dist/menu/validateMenu.js +63 -0
  63. package/dist/menu-browser/MenuBrowser.js +54 -0
  64. package/dist/menu-browser/MenuBrowser.module.css.js +153 -0
  65. package/dist/menu-browser/MenuCard.js +106 -0
  66. package/dist/menu-browser/MenuCartDrawer.js +18 -0
  67. package/dist/menu-browser/MenuCategoryBlock.js +45 -0
  68. package/dist/menu-browser/MenuItemSheet.js +132 -0
  69. package/dist/menu-browser/MenuLightbox.js +33 -0
  70. package/dist/menu-browser/MenuRow.js +43 -0
  71. package/dist/menu-browser/MenuSection.js +10 -0
  72. package/dist/menu-browser/menuItemDisplay.js +19 -0
  73. package/dist/menu-browser/useMediaQuery.js +19 -0
  74. package/dist/menu-browser/useMenuBrowserState.js +82 -0
  75. package/dist/menu-browser/useParkedSheet.js +32 -0
  76. package/dist/pages/AdminMenuEditor.js +291 -0
  77. package/dist/pages/AdminPreview.js +43 -0
  78. package/dist/pages/AdminPreview.module.css.js +9 -0
  79. package/dist/pages/MenuSite.js +40 -0
  80. package/dist/restaurant-kit.css +1 -0
  81. package/dist/testimonials/downscaleImage.js +14 -0
  82. package/dist/testimonials/testimonialMutations.js +52 -0
  83. package/dist/types/RestaurantSiteRoutes.d.ts +11 -0
  84. package/dist/types/admin/AdminActionMenu.d.ts +20 -0
  85. package/dist/types/admin/AdminAuthPanel.d.ts +13 -0
  86. package/dist/types/admin/AdminBackupsDialog.d.ts +9 -0
  87. package/dist/types/admin/AdminDraftProvider.d.ts +12 -0
  88. package/dist/types/admin/AdminGroupsPanel.d.ts +14 -0
  89. package/dist/types/admin/AdminItemDrawer.d.ts +16 -0
  90. package/dist/types/admin/AdminMenuCard.d.ts +10 -0
  91. package/dist/types/admin/AdminMenuGroupTabs.d.ts +11 -0
  92. package/dist/types/admin/AdminMenuIconPicker.d.ts +8 -0
  93. package/dist/types/admin/AdminRoutes.d.ts +5 -0
  94. package/dist/types/admin/AdminSectionEditor.d.ts +20 -0
  95. package/dist/types/admin/AdminTestimonialsPanel.d.ts +7 -0
  96. package/dist/types/admin/adminDraftContext.d.ts +2 -0
  97. package/dist/types/admin/adminEditorUtils.d.ts +22 -0
  98. package/dist/types/admin/adminMenuCommands.d.ts +73 -0
  99. package/dist/types/admin/useAdminDraft.d.ts +5 -0
  100. package/dist/types/admin/useAdminMenuDraft.d.ts +41 -0
  101. package/dist/types/cart/CartDrawer.d.ts +7 -0
  102. package/dist/types/cart/CartProvider.d.ts +4 -0
  103. package/dist/types/cart/cartContext.d.ts +2 -0
  104. package/dist/types/cart/cartOrder.d.ts +9 -0
  105. package/dist/types/cart/cartReducer.d.ts +36 -0
  106. package/dist/types/cart/cartTypes.d.ts +37 -0
  107. package/dist/types/cart/useCart.d.ts +1 -0
  108. package/dist/types/components/Brand.d.ts +5 -0
  109. package/dist/types/components/Footer.d.ts +1 -0
  110. package/dist/types/components/Header.d.ts +1 -0
  111. package/dist/types/components/Hero.d.ts +1 -0
  112. package/dist/types/components/InstagramIcon.d.ts +6 -0
  113. package/dist/types/components/LocationsSection.d.ts +1 -0
  114. package/dist/types/components/Marquee.d.ts +1 -0
  115. package/dist/types/components/OrderIcon.d.ts +4 -0
  116. package/dist/types/components/RatingSection.d.ts +1 -0
  117. package/dist/types/components/TestimonialsSection.d.ts +2 -0
  118. package/dist/types/components/WhatsAppIcon.d.ts +5 -0
  119. package/dist/types/config/RestaurantKitProvider.d.ts +24 -0
  120. package/dist/types/config/siteConfigContext.d.ts +28 -0
  121. package/dist/types/config/siteTypes.d.ts +50 -0
  122. package/dist/types/config/useOrdering.d.ts +16 -0
  123. package/dist/types/index.d.ts +38 -0
  124. package/dist/types/menu/localMenuApi.d.ts +9 -0
  125. package/dist/types/menu/menuApi.d.ts +68 -0
  126. package/dist/types/menu/menuDraftStorage.d.ts +5 -0
  127. package/dist/types/menu/menuIcons.d.ts +7 -0
  128. package/dist/types/menu/menuMutations.d.ts +13 -0
  129. package/dist/types/menu/menuOrdering.d.ts +38 -0
  130. package/dist/types/menu/menuSchema.d.ts +68 -0
  131. package/dist/types/menu/priceFormat.d.ts +8 -0
  132. package/dist/types/menu/useMenuData.d.ts +12 -0
  133. package/dist/types/menu/validateMenu.d.ts +16 -0
  134. package/dist/types/menu-browser/MenuBrowser.d.ts +6 -0
  135. package/dist/types/menu-browser/MenuCard.d.ts +7 -0
  136. package/dist/types/menu-browser/MenuCartDrawer.d.ts +5 -0
  137. package/dist/types/menu-browser/MenuCategoryBlock.d.ts +11 -0
  138. package/dist/types/menu-browser/MenuItemSheet.d.ts +10 -0
  139. package/dist/types/menu-browser/MenuLightbox.d.ts +9 -0
  140. package/dist/types/menu-browser/MenuRow.d.ts +7 -0
  141. package/dist/types/menu-browser/MenuSection.d.ts +1 -0
  142. package/dist/types/menu-browser/menuItemDisplay.d.ts +5 -0
  143. package/dist/types/menu-browser/useMediaQuery.d.ts +1 -0
  144. package/dist/types/menu-browser/useMenuBrowserState.d.ts +17 -0
  145. package/dist/types/menu-browser/useParkedSheet.d.ts +13 -0
  146. package/dist/types/pages/AdminMenuEditor.d.ts +1 -0
  147. package/dist/types/pages/AdminPreview.d.ts +1 -0
  148. package/dist/types/pages/MenuSite.d.ts +6 -0
  149. package/dist/types/testimonials/downscaleImage.d.ts +6 -0
  150. package/dist/types/testimonials/testimonialMutations.d.ts +9 -0
  151. package/dist/types/utils.d.ts +1 -0
  152. package/dist/types/worker/auth.d.ts +97 -0
  153. package/dist/types/worker/http.d.ts +17 -0
  154. package/dist/types/worker/index.d.ts +16 -0
  155. package/dist/types/worker/menuStorage.d.ts +94 -0
  156. package/dist/utils.js +6 -0
  157. package/dist/worker/auth.js +183 -0
  158. package/dist/worker/http.js +65 -0
  159. package/dist/worker/menuStorage.js +95 -0
  160. package/dist/worker.js +131 -0
  161. package/package.json +105 -0
  162. package/scripts/bootstrap-site.mjs +125 -0
  163. package/scripts/hash-admin-password.mjs +11 -0
  164. package/scripts/lib/password.mjs +72 -0
@@ -0,0 +1,94 @@
1
+ export type R2Object = {
2
+ readonly body: ReadableStream | null;
3
+ readonly httpEtag: string;
4
+ readonly writeHttpMetadata: (headers: Headers) => void;
5
+ };
6
+ export type R2PutOptions = {
7
+ readonly httpMetadata?: Record<string, string>;
8
+ /** R2 conditional write: the put is skipped (returns null) unless the stored etag matches. */
9
+ readonly onlyIf?: {
10
+ readonly etagMatches?: string;
11
+ };
12
+ };
13
+ export type R2PutResult = {
14
+ readonly httpEtag: string;
15
+ };
16
+ export type R2ListedObject = {
17
+ readonly key: string;
18
+ readonly size: number;
19
+ readonly uploaded: Date;
20
+ };
21
+ export type R2Bucket = {
22
+ readonly get: (key: string) => Promise<R2Object | null>;
23
+ readonly put: (key: string, value: string | ArrayBuffer | ReadableStream, options?: R2PutOptions) => Promise<R2PutResult | null | undefined | unknown>;
24
+ readonly delete: (key: string) => Promise<void>;
25
+ readonly list?: (options: {
26
+ readonly prefix: string;
27
+ readonly limit?: number;
28
+ }) => Promise<{
29
+ readonly objects: readonly R2ListedObject[];
30
+ }>;
31
+ };
32
+ export type MenuBackup = {
33
+ readonly key: string;
34
+ /** ISO timestamp of the publish that this backup preceded. */
35
+ readonly publishedAt: string;
36
+ readonly size: number;
37
+ };
38
+ export type RestoreSource = {
39
+ readonly kind: "published";
40
+ } | {
41
+ readonly kind: "backup";
42
+ readonly key: string;
43
+ };
44
+ export type RestoreDraftResult = {
45
+ readonly status: "restored";
46
+ readonly etag: string | null;
47
+ } | {
48
+ readonly status: "not-found";
49
+ } | {
50
+ readonly status: "invalid-key";
51
+ };
52
+ /** Browser-reported MIME types accepted for menu images; anything else is rejected with 415. */
53
+ export declare const allowedImageTypes: ReadonlySet<string>;
54
+ export declare const maxImageUploadBytes: number;
55
+ export type PublishResult = {
56
+ readonly status: "no-draft";
57
+ } | {
58
+ readonly status: "published";
59
+ readonly backupKey: string | null;
60
+ };
61
+ export type DraftRecord = {
62
+ readonly menu: unknown;
63
+ /** Quoted HTTP etag of the stored draft object, for If-Match round trips. */
64
+ readonly etag: string | null;
65
+ };
66
+ export type SaveDraftResult = {
67
+ readonly status: "saved";
68
+ readonly etag: string | null;
69
+ } | {
70
+ readonly status: "conflict";
71
+ };
72
+ export type MenuStorage = {
73
+ readonly getPublished: () => Promise<unknown | null>;
74
+ readonly getDraft: () => Promise<DraftRecord | null>;
75
+ readonly getAsset: (key: string) => Promise<R2Object | null>;
76
+ /** With `ifMatch`, the write only lands when the stored draft still carries that etag. */
77
+ readonly saveDraft: (menu: object, options?: {
78
+ readonly ifMatch?: string | null;
79
+ }) => Promise<SaveDraftResult>;
80
+ readonly publishDraft: () => Promise<PublishResult>;
81
+ readonly uploadImage: (file: File) => Promise<{
82
+ key: string;
83
+ }>;
84
+ readonly deleteImage: (key: string) => Promise<boolean>;
85
+ /** Backups written by publishDraft, newest first. */
86
+ readonly listBackups: () => Promise<readonly MenuBackup[]>;
87
+ /** Overwrites the draft with the published menu or one of its backups. */
88
+ readonly restoreDraft: (source: RestoreSource) => Promise<RestoreDraftResult>;
89
+ };
90
+ /** `published-2026-07-02T12-30-00-000Z.json` → `2026-07-02T12:30:00.000Z`; null when the key is not ours. */
91
+ export declare function backupTimestampFromKey(key: string): string | null;
92
+ /** HTTP etags arrive quoted (and possibly weak); R2's `etagMatches` wants the bare value. */
93
+ export declare function normalizeEtag(etag: string): string;
94
+ export declare function createMenuStorage(bucket: R2Bucket, restaurantId: string): MenuStorage;
package/dist/utils.js ADDED
@@ -0,0 +1,6 @@
1
+ function o(...n) {
2
+ return n.filter(Boolean).join(" ");
3
+ }
4
+ export {
5
+ o as cx
6
+ };
@@ -0,0 +1,183 @@
1
+ import { getAllowedOrigins as N } from "./http.js";
2
+ const f = "rk_admin_session", A = "pbkdf2-sha256", b = 1e5, O = 1e6, D = 336, y = "owner", d = "v1", c = new TextEncoder(), T = new TextDecoder();
3
+ function g(e) {
4
+ const t = e.ADMIN_AUTH?.trim().toLowerCase();
5
+ return t === "password" || t === "access" || t === "none" ? t : e.ADMIN_PASSWORD_HASH?.trim() ? "password" : p(e).length ? "access" : "none";
6
+ }
7
+ function p(e) {
8
+ return (e.ADMIN_EMAILS ?? "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
9
+ }
10
+ function M(e) {
11
+ const t = Number(e.ADMIN_SESSION_TTL_HOURS), n = Number.isFinite(t) && t > 0 ? t : D;
12
+ return Math.round(n * 3600);
13
+ }
14
+ function u(e) {
15
+ let t = "";
16
+ for (const n of e) t += String.fromCharCode(n);
17
+ return btoa(t).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ }
19
+ function S(e) {
20
+ const t = e.replace(/-/g, "+").replace(/_/g, "/"), n = t + "=".repeat((4 - t.length % 4) % 4), r = atob(n), s = new Uint8Array(r.length);
21
+ for (let o = 0; o < r.length; o += 1) s[o] = r.charCodeAt(o);
22
+ return s;
23
+ }
24
+ function h(e, t) {
25
+ const n = Math.max(e.length, t.length);
26
+ let r = e.length ^ t.length;
27
+ for (let s = 0; s < n; s += 1)
28
+ r |= (e[s] ?? 0) ^ (t[s] ?? 0);
29
+ return r === 0;
30
+ }
31
+ async function w(e, t, n) {
32
+ const r = await crypto.subtle.importKey("raw", c.encode(e), "PBKDF2", !1, ["deriveBits"]), s = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: t, iterations: n }, r, 256);
33
+ return new Uint8Array(s);
34
+ }
35
+ async function W(e, t = b) {
36
+ if (!e) throw new Error("Password must not be empty.");
37
+ const n = crypto.getRandomValues(new Uint8Array(16)), r = await w(e, n, t);
38
+ return [A, String(t), u(n), u(r)].join("$");
39
+ }
40
+ function m(e) {
41
+ const [t, n, r, s, ...o] = e.trim().split("$");
42
+ if (t !== A || o.length || !r || !s) return null;
43
+ const i = Number(n);
44
+ if (!Number.isInteger(i) || i < 1 || i > O) return null;
45
+ try {
46
+ return { iterations: i, salt: S(r), hash: S(s) };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ async function R(e, t) {
52
+ const n = m(t);
53
+ if (!n || !e) return !1;
54
+ const r = await w(e, n.salt, n.iterations);
55
+ return h(r, n.hash);
56
+ }
57
+ async function H(e) {
58
+ return crypto.subtle.importKey("raw", c.encode(e), { name: "HMAC", hash: "SHA-256" }, !1, ["sign", "verify"]);
59
+ }
60
+ async function _(e, t) {
61
+ const n = await H(e), r = await crypto.subtle.sign("HMAC", n, c.encode(t));
62
+ return u(new Uint8Array(r));
63
+ }
64
+ async function P(e, t, n, r = Date.now()) {
65
+ const s = Math.floor(r / 1e3), o = { sub: t, iat: s, exp: s + n }, i = u(c.encode(JSON.stringify(o))), l = await _(e, `${d}.${i}`);
66
+ return `${d}.${i}.${l}`;
67
+ }
68
+ async function C(e, t, n = Date.now()) {
69
+ const [r, s, o, ...i] = t.split(".");
70
+ if (r !== d || !s || !o || i.length) return null;
71
+ const l = await _(e, `${r}.${s}`);
72
+ if (!h(c.encode(o), c.encode(l))) return null;
73
+ try {
74
+ const a = JSON.parse(T.decode(S(s)));
75
+ return typeof a.sub != "string" || typeof a.exp != "number" || typeof a.iat != "number" || a.exp * 1e3 <= n ? null : { sub: a.sub, iat: a.iat, exp: a.exp };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ function x(e, t) {
81
+ const n = e.headers.get("cookie");
82
+ if (!n) return null;
83
+ for (const r of n.split(";")) {
84
+ const [s, ...o] = r.trim().split("=");
85
+ if (s === t) return o.join("=");
86
+ }
87
+ return null;
88
+ }
89
+ function I(e) {
90
+ return new URL(e.url).protocol === "https:";
91
+ }
92
+ function L(e, t, n) {
93
+ const r = [`${f}=${t}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${n}`];
94
+ return I(e) && r.push("Secure"), r.join("; ");
95
+ }
96
+ function F(e) {
97
+ const t = [`${f}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"];
98
+ return I(e) && t.push("Secure"), t.join("; ");
99
+ }
100
+ function j(e, t) {
101
+ const n = e.headers.get("origin");
102
+ return !n || n === new URL(e.url).origin ? !0 : N(t).includes(n);
103
+ }
104
+ function $(e, t) {
105
+ const n = p(t);
106
+ if (!n.length) return null;
107
+ const r = e.headers.get("cf-access-authenticated-user-email")?.toLowerCase();
108
+ return !r || !n.includes(r) ? null : { user: r, strategy: "access" };
109
+ }
110
+ function E(e) {
111
+ return e.ADMIN_SESSION_SECRET ? `${e.ADMIN_SESSION_SECRET}\0${e.ADMIN_PASSWORD_HASH ?? ""}` : null;
112
+ }
113
+ async function k(e, t) {
114
+ const n = E(t), r = x(e, f);
115
+ if (!n || !r) return null;
116
+ const s = await C(n, r);
117
+ return s ? { user: s.sub, strategy: "password" } : null;
118
+ }
119
+ async function U(e, t) {
120
+ switch (g(t)) {
121
+ case "password":
122
+ return k(e, t);
123
+ case "access":
124
+ return $(e, t);
125
+ default:
126
+ return null;
127
+ }
128
+ }
129
+ async function J(e, t) {
130
+ const n = g(t), r = await U(e, t);
131
+ return { authenticated: !!r, strategy: n, user: r?.user ?? null };
132
+ }
133
+ function K(e) {
134
+ return e.headers.get("cf-connecting-ip") ?? "unknown";
135
+ }
136
+ async function G(e, t) {
137
+ if (g(t) !== "password")
138
+ return { status: "misconfigured", message: "Password sign-in is not enabled for this API." };
139
+ if (!t.ADMIN_PASSWORD_HASH || !m(t.ADMIN_PASSWORD_HASH))
140
+ return { status: "misconfigured", message: "ADMIN_PASSWORD_HASH is missing or malformed." };
141
+ if (!t.ADMIN_SESSION_SECRET || t.ADMIN_SESSION_SECRET.length < 16)
142
+ return { status: "misconfigured", message: "ADMIN_SESSION_SECRET is missing or too short (min 16 chars)." };
143
+ let n = "";
144
+ try {
145
+ const o = await e.json();
146
+ n = typeof o.password == "string" ? o.password : "";
147
+ } catch {
148
+ return { status: "bad-request", message: "Expected a JSON body with a password field." };
149
+ }
150
+ if (!n) return { status: "bad-request", message: "Password is required." };
151
+ if (t.LOGIN_RATE_LIMITER) {
152
+ const { success: o } = await t.LOGIN_RATE_LIMITER.limit({ key: `login:${K(e)}` });
153
+ if (!o) return { status: "rate-limited" };
154
+ }
155
+ if (!await R(n, t.ADMIN_PASSWORD_HASH)) return { status: "wrong-password" };
156
+ const r = M(t), s = await P(E(t) ?? t.ADMIN_SESSION_SECRET, y, r);
157
+ return { status: "ok", user: y, setCookie: L(e, s, r) };
158
+ }
159
+ export {
160
+ b as DEFAULT_PBKDF2_ITERATIONS,
161
+ A as PASSWORD_HASH_ALGORITHM,
162
+ f as SESSION_COOKIE_NAME,
163
+ S as base64UrlDecode,
164
+ u as base64UrlEncode,
165
+ F as buildClearedSessionCookie,
166
+ L as buildSessionCookie,
167
+ P as createSessionToken,
168
+ $ as getAccessIdentity,
169
+ U as getAdminIdentity,
170
+ J as getAdminSessionInfo,
171
+ p as getAllowedAdminEmails,
172
+ k as getPasswordSessionIdentity,
173
+ E as getSessionSigningKey,
174
+ W as hashPassword,
175
+ j as isTrustedOrigin,
176
+ G as loginWithPassword,
177
+ m as parsePasswordHash,
178
+ x as readCookie,
179
+ g as resolveAdminAuthStrategy,
180
+ h as timingSafeEqual,
181
+ R as verifyPassword,
182
+ C as verifySessionToken
183
+ };
@@ -0,0 +1,65 @@
1
+ class a extends Error {
2
+ status;
3
+ constructor(t, e) {
4
+ super(t), this.name = "RequestError", this.status = e;
5
+ }
6
+ }
7
+ const c = {
8
+ "content-type": "application/json; charset=utf-8"
9
+ };
10
+ function i(r) {
11
+ return (r.ALLOWED_ORIGINS ?? "").split(",").map((t) => t.trim()).filter(Boolean);
12
+ }
13
+ function o(r) {
14
+ const t = i(r), n = t.find((s) => s.startsWith("https://") && !s.includes("staging")) ?? t.find((s) => s.startsWith("https://")) ?? t[0];
15
+ return n ? `${n}/admin` : "/admin";
16
+ }
17
+ function l(r, t) {
18
+ const e = r.headers.get("origin"), n = i(t);
19
+ return !e || !n.includes(e) ? { vary: "Origin" } : {
20
+ "access-control-allow-origin": e,
21
+ "access-control-allow-credentials": "true",
22
+ "access-control-allow-methods": "GET, PUT, POST, DELETE, OPTIONS",
23
+ "access-control-allow-headers": "content-type, if-match",
24
+ "access-control-expose-headers": "etag",
25
+ vary: "Origin"
26
+ };
27
+ }
28
+ function u(r, t) {
29
+ const e = new URL(r.url).searchParams.get("returnTo");
30
+ if (!e) return o(t);
31
+ try {
32
+ const n = new URL(e);
33
+ if (i(t).includes(n.origin)) return n.toString();
34
+ } catch {
35
+ return o(t);
36
+ }
37
+ return o(t);
38
+ }
39
+ function d(r, t) {
40
+ return new Response(null, {
41
+ status: 302,
42
+ headers: {
43
+ location: r,
44
+ ...t
45
+ }
46
+ });
47
+ }
48
+ function g(r, t, e = {}) {
49
+ return new Response(JSON.stringify(r, null, 2), {
50
+ ...t,
51
+ headers: {
52
+ ...c,
53
+ ...e,
54
+ ...t?.headers
55
+ }
56
+ });
57
+ }
58
+ export {
59
+ a as RequestError,
60
+ i as getAllowedOrigins,
61
+ l as getCorsHeaders,
62
+ u as getSafeReturnTo,
63
+ g as jsonResponse,
64
+ d as redirectResponse
65
+ };
@@ -0,0 +1,95 @@
1
+ import { RequestError as p } from "./http.js";
2
+ const d = 30, g = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif"]), c = 5 * 1024 * 1024;
3
+ function r(e) {
4
+ const a = `restaurants/${e}`;
5
+ return {
6
+ published: `${a}/published/menu.json`,
7
+ draft: `${a}/draft/menu.json`,
8
+ backup: `${a}/backups/published-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`,
9
+ backupPrefix: `${a}/backups/`,
10
+ imagePrefix: `${a}/images/menu`
11
+ };
12
+ }
13
+ function m(e) {
14
+ const a = e.match(/published-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z\.json$/);
15
+ if (!a) return null;
16
+ const [, t, i, n, s, l] = a;
17
+ return `${t}T${i}:${n}:${s}.${l}Z`;
18
+ }
19
+ function h(e) {
20
+ return e.trim().replace(/^W\//i, "").replace(/^"(.*)"$/, "$1");
21
+ }
22
+ function w(e) {
23
+ return e.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/^-+|-+$/g, "") || "menu-image";
24
+ }
25
+ async function f(e, a) {
26
+ const t = await e.get(a);
27
+ return t ? { menu: await new Response(t.body).json(), etag: t.httpEtag ?? null } : null;
28
+ }
29
+ async function u(e, a) {
30
+ return (await f(e, a))?.menu ?? null;
31
+ }
32
+ function y(e) {
33
+ return !!(e && typeof e == "object" && "httpEtag" in e);
34
+ }
35
+ async function o(e, a, t, i) {
36
+ const n = await e.put(a, JSON.stringify(t, null, 2), {
37
+ httpMetadata: {
38
+ contentType: "application/json; charset=utf-8"
39
+ },
40
+ ...i ? { onlyIf: i } : {}
41
+ });
42
+ return n === null ? null : { etag: y(n) ? n.httpEtag : null };
43
+ }
44
+ function b(e, a) {
45
+ return {
46
+ getPublished: () => u(e, r(a).published),
47
+ getDraft: () => f(e, r(a).draft),
48
+ // Only objects under this restaurant's image prefix are servable; the draft menu, backups,
49
+ // and other tenants' keys must stay unreachable through /api/assets/*.
50
+ getAsset: async (t) => t.startsWith(`${r(a).imagePrefix}/`) ? e.get(t) : null,
51
+ saveDraft: async (t, i = {}) => {
52
+ const n = i.ifMatch ? { etagMatches: h(i.ifMatch) } : void 0, s = await o(e, r(a).draft, { ...t, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, n);
53
+ return s ? { status: "saved", etag: s.etag } : { status: "conflict" };
54
+ },
55
+ publishDraft: async () => {
56
+ const t = r(a), i = await u(e, t.draft);
57
+ if (!i) return { status: "no-draft" };
58
+ const n = await u(e, t.published);
59
+ return n && await o(e, t.backup, n), await o(e, t.published, { ...i, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }), { status: "published", backupKey: n ? t.backup : null };
60
+ },
61
+ uploadImage: async (t) => {
62
+ if (!g.has(t.type)) throw new p("Unsupported image type. Use JPEG, PNG, WebP, AVIF, or GIF.", 415);
63
+ if (t.size > c) throw new p(`Image is too large. Maximum size is ${c / (1024 * 1024)} MB.`, 413);
64
+ const i = `${r(a).imagePrefix}/${Date.now()}-${w(t.name)}`;
65
+ return await e.put(i, await t.arrayBuffer(), {
66
+ httpMetadata: {
67
+ contentType: t.type
68
+ }
69
+ }), { key: i };
70
+ },
71
+ deleteImage: async (t) => t.startsWith(`${r(a).imagePrefix}/`) ? (await e.delete(t), !0) : !1,
72
+ listBackups: async () => {
73
+ if (!e.list) return [];
74
+ const { backupPrefix: t } = r(a), { objects: i } = await e.list({ prefix: t, limit: 1e3 });
75
+ return i.map((n) => ({ key: n.key, publishedAt: m(n.key) ?? n.uploaded.toISOString(), size: n.size })).sort((n, s) => s.publishedAt.localeCompare(n.publishedAt)).slice(0, d);
76
+ },
77
+ restoreDraft: async (t) => {
78
+ const i = r(a);
79
+ let n = i.published;
80
+ if (t.kind === "backup") {
81
+ if (!t.key.startsWith(i.backupPrefix) || t.key.includes("..")) return { status: "invalid-key" };
82
+ n = t.key;
83
+ }
84
+ const s = await u(e, n);
85
+ return s ? { status: "restored", etag: (await o(e, i.draft, { ...s, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }))?.etag ?? null } : { status: "not-found" };
86
+ }
87
+ };
88
+ }
89
+ export {
90
+ g as allowedImageTypes,
91
+ m as backupTimestampFromKey,
92
+ b as createMenuStorage,
93
+ c as maxImageUploadBytes,
94
+ h as normalizeEtag
95
+ };
package/dist/worker.js ADDED
@@ -0,0 +1,131 @@
1
+ import { isTrustedOrigin as p, getAdminIdentity as l, resolveAdminAuthStrategy as w, getAdminSessionInfo as g, loginWithPassword as y, buildClearedSessionCookie as b } from "./worker/auth.js";
2
+ import { hashPassword as $, verifyPassword as z } from "./worker/auth.js";
3
+ import { getCorsHeaders as k, redirectResponse as T, getSafeReturnTo as S, jsonResponse as r, RequestError as d } from "./worker/http.js";
4
+ import { createMenuStorage as E, allowedImageTypes as R } from "./worker/menuStorage.js";
5
+ const i = { "cache-control": "no-store" };
6
+ function A(t) {
7
+ if (!t || typeof t != "object") throw new d("Menu must be an object.", 400);
8
+ const n = t;
9
+ if (n.schemaVersion !== 1) throw new d("Unsupported menu schema version.", 400);
10
+ if (!Array.isArray(n.groups)) throw new d("Menu groups must be an array.", 400);
11
+ if (!Array.isArray(n.categories)) throw new d("Menu categories must be an array.", 400);
12
+ if (!Array.isArray(n.items)) throw new d("Menu items must be an array.", 400);
13
+ }
14
+ async function c(t) {
15
+ try {
16
+ return await t.json();
17
+ } catch {
18
+ throw new d("Request body must be valid JSON.", 400);
19
+ }
20
+ }
21
+ async function P(t) {
22
+ try {
23
+ return await t.formData();
24
+ } catch {
25
+ throw new d("Request body must be multipart form data.", 400);
26
+ }
27
+ }
28
+ async function M(t, n) {
29
+ const e = await t.getPublished();
30
+ return e ? r(e, {
31
+ headers: {
32
+ "cache-control": "no-store"
33
+ }
34
+ }, n) : r({ error: "Published menu not found." }, { status: 404 }, n);
35
+ }
36
+ async function U(t, n, e) {
37
+ const a = new URL(t.url), s = decodeURIComponent(a.pathname.replace(/^\/api\/assets\//, "")), o = await n.getAsset(s);
38
+ if (!o) return new Response("Not found", { status: 404 });
39
+ const u = new Headers();
40
+ o.writeHttpMetadata(u);
41
+ const h = u.get("content-type") ?? "";
42
+ return R.has(h) ? u.set("content-disposition", "inline") : (u.set("content-type", "application/octet-stream"), u.set("content-disposition", "attachment")), u.set("x-content-type-options", "nosniff"), u.set("etag", o.httpEtag), u.set("cache-control", "public, max-age=31536000, immutable"), Object.entries(e).forEach(([m, f]) => u.set(m, f)), new Response(o.body, { headers: u });
43
+ }
44
+ async function D(t, n, e) {
45
+ if (t.method === "GET") {
46
+ const a = await n.getDraft();
47
+ if (a)
48
+ return r(a.menu, { headers: { ...i, ...a.etag ? { etag: a.etag } : {} } }, e);
49
+ const s = await n.getPublished();
50
+ return s ? r(s, { headers: i }, e) : r({ error: "No draft or published menu found." }, { status: 404 }, e);
51
+ }
52
+ if (t.method === "PUT") {
53
+ const a = await c(t);
54
+ A(a);
55
+ const s = t.headers.get("if-match"), o = await n.saveDraft(a, { ifMatch: s && s !== "*" ? s : null });
56
+ return o.status === "conflict" ? r({ error: "The draft was changed elsewhere. Reload the editor to get the latest version, then redo your edits." }, { status: 412, headers: i }, e) : r({ ok: !0, etag: o.etag }, { headers: { ...i, ...o.etag ? { etag: o.etag } : {} } }, e);
57
+ }
58
+ return r({ error: "Method not allowed." }, { status: 405 }, e);
59
+ }
60
+ async function I(t, n) {
61
+ const e = await t.listBackups();
62
+ return r({ backups: e }, { headers: i }, n);
63
+ }
64
+ async function O(t, n, e) {
65
+ const a = await c(t);
66
+ let s;
67
+ if (a.source === "published") s = { kind: "published" };
68
+ else if (a.source === "backup" && typeof a.key == "string" && a.key) s = { kind: "backup", key: a.key };
69
+ else throw new d('Expected { source: "published" } or { source: "backup", key }.', 400);
70
+ const o = await n.restoreDraft(s);
71
+ if (o.status === "invalid-key") throw new d("Backup key is outside this restaurant's backups.", 400);
72
+ return o.status === "not-found" ? r({ error: s.kind === "published" ? "Nothing has been published yet." : "Backup not found." }, { status: 404 }, e) : r({ ok: !0, etag: o.etag }, { headers: { ...i, ...o.etag ? { etag: o.etag } : {} } }, e);
73
+ }
74
+ async function x(t, n) {
75
+ const e = await t.publishDraft();
76
+ return e.status === "no-draft" ? r({ error: "Draft menu not found." }, { status: 404 }, n) : r({ ok: !0, backupKey: e.backupKey }, void 0, n);
77
+ }
78
+ async function C(t, n, e) {
79
+ const s = (await P(t)).get("file");
80
+ if (!(s instanceof File)) return r({ error: "Missing file field." }, { status: 400 }, e);
81
+ const { key: o } = await n.uploadImage(s);
82
+ return r({ key: o, url: `${new URL(t.url).origin}/api/assets/${encodeURIComponent(o)}` }, void 0, e);
83
+ }
84
+ async function N(t, n, e) {
85
+ const s = new URL(t.url).searchParams.get("key");
86
+ return s ? await n.deleteImage(s) ? r({ ok: !0 }, void 0, e) : r({ error: "Image key is outside menu image prefix." }, { status: 400 }, e) : r({ error: "Missing key." }, { status: 400 }, e);
87
+ }
88
+ async function G(t, n, e) {
89
+ const a = await g(t, n);
90
+ return r(a, { headers: i }, e);
91
+ }
92
+ async function L(t, n, e) {
93
+ const a = await y(t, n);
94
+ switch (a.status) {
95
+ case "ok":
96
+ return r(
97
+ { ok: !0, user: a.user, strategy: "password" },
98
+ { headers: { ...i, "set-cookie": a.setCookie } },
99
+ e
100
+ );
101
+ case "wrong-password":
102
+ return r({ error: "Wrong password." }, { status: 401, headers: i }, e);
103
+ case "rate-limited":
104
+ return r({ error: "Too many sign-in attempts. Wait a minute and try again." }, { status: 429, headers: i }, e);
105
+ case "bad-request":
106
+ return r({ error: a.message }, { status: 400 }, e);
107
+ case "misconfigured":
108
+ return r({ error: a.message }, { status: 500 }, e);
109
+ }
110
+ }
111
+ function W(t, n) {
112
+ return r({ ok: !0 }, { headers: { ...i, "set-cookie": b(t) } }, n);
113
+ }
114
+ const F = {
115
+ async fetch(t, n) {
116
+ const e = new URL(t.url);
117
+ if (!e.pathname.startsWith("/api/") && n.ASSETS) return n.ASSETS.fetch(t);
118
+ const a = k(t, n), s = E(n.MENU_BUCKET, n.RESTAURANT_ID);
119
+ try {
120
+ return t.method === "OPTIONS" ? new Response(null, { status: 204, headers: a }) : t.method === "GET" && e.pathname === "/api/menu" ? await M(s, a) : t.method === "GET" && e.pathname.startsWith("/api/assets/") ? await U(t, s, a) : t.method === "GET" && e.pathname === "/api/admin/sign-in" ? T(S(t, n), a) : t.method === "GET" && e.pathname === "/api/admin/session" ? await G(t, n, a) : e.pathname.startsWith("/api/admin/") && t.method !== "GET" && !p(t, n) ? r({ error: "Cross-origin request blocked." }, { status: 403 }, a) : t.method === "POST" && e.pathname === "/api/admin/login" ? await L(t, n, a) : t.method === "POST" && e.pathname === "/api/admin/logout" ? await W(t, a) : e.pathname.startsWith("/api/admin/") && !await l(t, n) ? r({ error: "Unauthorized.", strategy: w(n) }, { status: 401, headers: i }, a) : e.pathname === "/api/admin/menu/draft" ? await D(t, s, a) : t.method === "POST" && e.pathname === "/api/admin/menu/publish" ? await x(s, a) : t.method === "GET" && e.pathname === "/api/admin/menu/backups" ? await I(s, a) : t.method === "POST" && e.pathname === "/api/admin/menu/restore" ? await O(t, s, a) : t.method === "POST" && e.pathname === "/api/admin/images" ? await C(t, s, a) : t.method === "DELETE" && e.pathname === "/api/admin/images" ? await N(t, s, a) : r({ error: "Not found." }, { status: 404 }, a);
121
+ } catch (o) {
122
+ return o instanceof d ? r({ error: o.message }, { status: o.status }, a) : (console.error("menu api error", o), r({ error: "Unexpected error." }, { status: 500 }, a));
123
+ }
124
+ }
125
+ };
126
+ export {
127
+ F as default,
128
+ $ as hashPassword,
129
+ w as resolveAdminAuthStrategy,
130
+ z as verifyPassword
131
+ };
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "restaurant-kit",
3
+ "version": "0.1.0",
4
+ "description": "Restaurant website kit for React + Cloudflare: public menu, WhatsApp or delivery-app ordering, owner-editable menu CMS, and a Worker API backed by R2.",
5
+ "keywords": [
6
+ "restaurant",
7
+ "menu",
8
+ "cms",
9
+ "react",
10
+ "cloudflare",
11
+ "cloudflare-workers",
12
+ "r2",
13
+ "whatsapp",
14
+ "admin"
15
+ ],
16
+ "author": "Hadi Al Hassan (https://hadialhassan.dev)",
17
+ "homepage": "https://github.com/HadiAlHassan/restaurant-kit#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/HadiAlHassan/restaurant-kit.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/HadiAlHassan/restaurant-kit/issues"
24
+ },
25
+ "type": "module",
26
+ "license": "MIT",
27
+ "files": [
28
+ "dist",
29
+ "scripts"
30
+ ],
31
+ "bin": {
32
+ "restaurant-kit-hash-password": "./scripts/hash-admin-password.mjs",
33
+ "restaurant-kit-bootstrap": "./scripts/bootstrap-site.mjs"
34
+ },
35
+ "main": "./dist/index.js",
36
+ "types": "./dist/types/index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "types": "./dist/types/index.d.ts",
40
+ "import": "./dist/index.js"
41
+ },
42
+ "./worker": {
43
+ "types": "./dist/types/worker/index.d.ts",
44
+ "import": "./dist/worker.js"
45
+ },
46
+ "./styles.css": "./dist/restaurant-kit.css"
47
+ },
48
+ "sideEffects": [
49
+ "*.css"
50
+ ],
51
+ "scripts": {
52
+ "build": "npm run typecheck && vite build && tsc -p tsconfig.build.json --emitDeclarationOnly --noEmit false --outDir dist/types --tsBuildInfoFile node_modules/.tmp/tsconfig.types.tsbuildinfo",
53
+ "dev": "vite build --watch",
54
+ "lint": "eslint .",
55
+ "test": "vitest run",
56
+ "typecheck": "tsc -b tsconfig.build.json",
57
+ "prepublishOnly": "npm run lint && npm test && npm run build"
58
+ },
59
+ "dependencies": {
60
+ "@dnd-kit/abstract": "^0.5.0",
61
+ "@dnd-kit/helpers": "^0.5.0",
62
+ "@dnd-kit/react": "^0.5.0",
63
+ "@radix-ui/react-dropdown-menu": "^2.1.19",
64
+ "@radix-ui/react-popover": "^1.1.18",
65
+ "lucide-react": "^1.48.0",
66
+ "react-image-crop": "^11.1.2",
67
+ "sonner": "^2.0.7"
68
+ },
69
+ "peerDependencies": {
70
+ "react": "^19.1.0",
71
+ "react-dom": "^19.1.0",
72
+ "react-router-dom": "^7.18.1"
73
+ },
74
+ "devDependencies": {
75
+ "@cloudflare/workers-types": "^4.20250109.0",
76
+ "@eslint/js": "^9.30.0",
77
+ "@testing-library/dom": "^10.4.1",
78
+ "@testing-library/react": "^16.3.2",
79
+ "@types/node": "^24.0.0",
80
+ "@types/react": "^19.1.0",
81
+ "@types/react-dom": "^19.1.0",
82
+ "@vitejs/plugin-react": "^5.0.0",
83
+ "autoprefixer": "^10.6.1",
84
+ "eslint": "^9.30.0",
85
+ "eslint-plugin-react-hooks": "^5.2.0",
86
+ "eslint-plugin-react-refresh": "^0.4.20",
87
+ "globals": "^16.2.0",
88
+ "jsdom": "^29.1.1",
89
+ "postcss": "^8.5.28",
90
+ "react": "^19.1.0",
91
+ "react-dom": "^19.1.0",
92
+ "react-router-dom": "^7.18.1",
93
+ "tailwindcss": "^3.4.19",
94
+ "typescript": "^5.8.0",
95
+ "typescript-eslint": "^8.35.0",
96
+ "vite": "^7.0.0",
97
+ "vitest": "4.1.9"
98
+ },
99
+ "engines": {
100
+ "node": ">=20"
101
+ },
102
+ "publishConfig": {
103
+ "access": "public"
104
+ }
105
+ }