@guard-rail/core 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -389,6 +389,52 @@ scratch. A visual page builder (Puck, say) is a better fit for that and can
389
389
  sit alongside `<GRepeater>` later — it would decide composition, while
390
390
  `<GRail>` still owns each block's leaf content.
391
391
 
392
+ ## Custom editing UI
393
+
394
+ `<GRail>` edits what's on the page. Some fields have nothing to click — an SEO
395
+ title, a meta description, an og:image — and some data isn't copy at all, like
396
+ stock or price. For those, build a small drawer of your own and keep the
397
+ authorization in Guardrail's hands:
398
+
399
+ - **`getFieldValue(id)`** reads any field (server-side).
400
+ - **`setFieldValue(id, value)`** writes one. A string is shorthand for a text
401
+ field; pass `{ type: "image", src, alt }` etc. for the others. It checks the
402
+ caller is an editor or admin itself, and writes the same row a `<GRail>` with
403
+ that id would, so the two stay interchangeable.
404
+ - **`isEditor()` / `isAdmin()`** are the role checks for your *own* Server
405
+ Actions. `isEditor()` is true for editors and admins; `isAdmin()` only for
406
+ admins (team management).
407
+ - **`canEdit()`** is true when the viewer holds a role *and* the site is in
408
+ edit mode — the one check for "show my editing UI." Use it in Server
409
+ Components; Client Components read the same flag as `useGRail().canEdit`.
410
+
411
+ ```tsx
412
+ // app/products/[slug]/actions.ts — a hand-rolled save for your own data
413
+ "use server";
414
+ import { isEditor } from "@guard-rail/core";
415
+
416
+ export async function restock(sku: string, quantity: number) {
417
+ if (!(await isEditor())) return { success: false, error: "Unauthorized." };
418
+ // ...write to your own stock table
419
+ }
420
+ ```
421
+
422
+ Show the trigger only when `canEdit` is true — not `isEditMode` alone, since
423
+ edit mode can be switched on by anyone visiting `?edit=true`; `canEdit` also
424
+ requires a role on the site. In a small client component:
425
+
426
+ ```tsx
427
+ "use client";
428
+ function StockEditTrigger({ children }: { children: React.ReactNode }) {
429
+ const { canEdit } = useGRail();
430
+ if (!canEdit) return <>{children}</>;
431
+ return <button onClick={openDrawer}>{children}</button>;
432
+ }
433
+ ```
434
+
435
+ `canEdit` only decides what to *show*: every action behind it must still call
436
+ `isEditor()` itself, because a hidden button is not authorization.
437
+
392
438
  ## Appearance
393
439
 
394
440
  Guardrail's own chrome (sign-in bar, toolbar, edit popups, image picker, team
@@ -119,6 +119,14 @@ interface GRState {
119
119
  siteId: string;
120
120
  /** Null when the signed-in user holds no role on this site. */
121
121
  role: GRRole | null;
122
+ /**
123
+ * The viewer holds a role on this site AND the site is in edit mode — the
124
+ * one flag to gate custom editing UI on. Equivalent to
125
+ * `isEditMode && role !== null`; server-side, the same answer is canEdit().
126
+ * Decides what to *show* only — a Server Action behind it must still call
127
+ * isEditor() itself.
128
+ */
129
+ canEdit: boolean;
122
130
  email?: string;
123
131
  auth: GRAuthCapabilities;
124
132
  /** A storage adapter is configured, so images can be uploaded and picked. */
@@ -1,6 +1,6 @@
1
1
  export { GRail } from './react/GRail/universal.js';
2
2
  export { useGRail } from './react/Provider.js';
3
- export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
3
+ export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
4
4
  export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
5
5
  export { GRepeaterProps, RepeaterItemProps } from './react/Repeater/index.js';
6
6
  export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
@@ -14,5 +14,8 @@ declare const GRepeater: (..._args: unknown[]) => never;
14
14
  declare const listRepeaterItems: (..._args: unknown[]) => never;
15
15
  declare const getRepeaterItemBySlug: (..._args: unknown[]) => never;
16
16
  declare const getFieldValue: (..._args: unknown[]) => never;
17
+ declare const isAdmin: (..._args: unknown[]) => never;
18
+ declare const isEditor: (..._args: unknown[]) => never;
19
+ declare const canEdit: (..._args: unknown[]) => never;
17
20
 
18
- export { GRepeater, configureGuardrail, getFieldValue, getRepeaterItemBySlug, listRepeaterItems, withGuardrail };
21
+ export { GRepeater, canEdit, configureGuardrail, getFieldValue, getRepeaterItemBySlug, isAdmin, isEditor, listRepeaterItems, withGuardrail };
@@ -8,7 +8,8 @@ import {
8
8
  createAssetUpload,
9
9
  listAssets,
10
10
  setPasswordAction,
11
- skipSetPasswordAction
11
+ skipSetPasswordAction,
12
+ setFieldValue
12
13
  } from "./server/actions";
13
14
  import {
14
15
  addRepeaterItem,
@@ -46,21 +47,37 @@ const getFieldValue = serverOnly(
46
47
  "getFieldValue()",
47
48
  "Read the value in a Server Component and pass it down as a prop."
48
49
  );
50
+ const isAdmin = serverOnly(
51
+ "isAdmin()",
52
+ "Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show."
53
+ );
54
+ const isEditor = serverOnly(
55
+ "isEditor()",
56
+ "Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show."
57
+ );
58
+ const canEdit = serverOnly(
59
+ "canEdit()",
60
+ "Call it in a Server Component; in a Client Component, read `canEdit` from useGRail()."
61
+ );
49
62
  export {
50
63
  GRail,
51
64
  GRepeater,
52
65
  addRepeaterItem,
66
+ canEdit,
53
67
  configureGuardrail,
54
68
  createAssetUpload,
55
69
  getFieldValue,
56
70
  getRepeaterItemBySlug,
57
71
  inviteMember,
72
+ isAdmin,
73
+ isEditor,
58
74
  listAssets,
59
75
  listRepeaterItems,
60
76
  listTeam,
61
77
  removeMember,
62
78
  removeRepeaterItem,
63
79
  reorderRepeaterItems,
80
+ setFieldValue,
64
81
  setPasswordAction,
65
82
  skipSetPasswordAction,
66
83
  updateMemberRole,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.client.ts"],"sourcesContent":["// Client-graph entry. `import ... from \"@guard-rail/core\"` resolves here from a\n// Client Component (see \"exports\" in package.json) and to ./index from a\n// Server Component, so an app uses one import path everywhere.\n//\n// Nothing server-only may be imported here — this is bundled for the browser.\n// Server Actions are fine: a \"use server\" module is replaced by references in\n// the client bundle, which is what lets an app build its own team/repeater UI\n// in a Client Component.\n\nexport { GRail } from \"./react/GRail/universal\";\nexport { useGRail } from \"./react/Provider\";\n\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"./server/actions\";\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\n\n/**\n * Stand-ins for the server-only exports, so importing one from a Client\n * Component fails with an explanation instead of an undefined-is-not-a-function\n * deep in a render. Also what a tool that doesn't set the \"react-server\"\n * condition (a test runner, a plain Node script) gets from the package root —\n * `@guard-rail/core/server` is the explicit path to the real ones there.\n */\nfunction serverOnly(name: string, hint: string) {\n return (..._args: unknown[]): never => {\n throw new Error(\n `Guardrail: ${name} only works in a Server Component. ${hint} ` +\n `(If this isn't a Client Component, your tooling may not set the ` +\n `\"react-server\" condition — import from \"@guard-rail/core/server\" instead.)`,\n );\n };\n}\n\nexport const withGuardrail = serverOnly(\n \"withGuardrail()\",\n \"Call it in your root layout, which is a Server Component.\",\n);\nexport const configureGuardrail = serverOnly(\n \"configureGuardrail()\",\n \"Configuration is applied by withGuardrail() in your root layout.\",\n);\nexport const GRepeater = serverOnly(\n \"<GRepeater>\",\n \"Render it in a Server Component and pass it into this one as children or a prop.\",\n);\nexport const listRepeaterItems = serverOnly(\n \"listRepeaterItems()\",\n \"Read repeater items in a Server Component and pass them down.\",\n);\nexport const getRepeaterItemBySlug = serverOnly(\n \"getRepeaterItemBySlug()\",\n \"Resolve the item in a Server Component (your [slug]/page.tsx) and pass it down.\",\n);\nexport const getFieldValue = serverOnly(\n \"getFieldValue()\",\n \"Read the value in a Server Component and pass it down as a prop.\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AASA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAEzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,WAAW,MAAc,MAAc;AAC9C,SAAO,IAAI,UAA4B;AACrC,UAAM,IAAI;AAAA,MACR,cAAc,IAAI,sCAAsC,IAAI;AAAA,IAG9D;AAAA,EACF;AACF;AAEO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AACF;AACO,MAAM,YAAY;AAAA,EACvB;AAAA,EACA;AACF;AACO,MAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AACF;AACO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.client.ts"],"sourcesContent":["// Client-graph entry. `import ... from \"@guard-rail/core\"` resolves here from a\n// Client Component (see \"exports\" in package.json) and to ./index from a\n// Server Component, so an app uses one import path everywhere.\n//\n// Nothing server-only may be imported here — this is bundled for the browser.\n// Server Actions are fine: a \"use server\" module is replaced by references in\n// the client bundle, which is what lets an app build its own team/repeater UI\n// in a Client Component.\n\nexport { GRail } from \"./react/GRail/universal\";\nexport { useGRail } from \"./react/Provider\";\n\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n setFieldValue,\n} from \"./server/actions\";\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\n\n/**\n * Stand-ins for the server-only exports, so importing one from a Client\n * Component fails with an explanation instead of an undefined-is-not-a-function\n * deep in a render. Also what a tool that doesn't set the \"react-server\"\n * condition (a test runner, a plain Node script) gets from the package root —\n * `@guard-rail/core/server` is the explicit path to the real ones there.\n */\nfunction serverOnly(name: string, hint: string) {\n return (..._args: unknown[]): never => {\n throw new Error(\n `Guardrail: ${name} only works in a Server Component. ${hint} ` +\n `(If this isn't a Client Component, your tooling may not set the ` +\n `\"react-server\" condition — import from \"@guard-rail/core/server\" instead.)`,\n );\n };\n}\n\nexport const withGuardrail = serverOnly(\n \"withGuardrail()\",\n \"Call it in your root layout, which is a Server Component.\",\n);\nexport const configureGuardrail = serverOnly(\n \"configureGuardrail()\",\n \"Configuration is applied by withGuardrail() in your root layout.\",\n);\nexport const GRepeater = serverOnly(\n \"<GRepeater>\",\n \"Render it in a Server Component and pass it into this one as children or a prop.\",\n);\nexport const listRepeaterItems = serverOnly(\n \"listRepeaterItems()\",\n \"Read repeater items in a Server Component and pass them down.\",\n);\nexport const getRepeaterItemBySlug = serverOnly(\n \"getRepeaterItemBySlug()\",\n \"Resolve the item in a Server Component (your [slug]/page.tsx) and pass it down.\",\n);\nexport const getFieldValue = serverOnly(\n \"getFieldValue()\",\n \"Read the value in a Server Component and pass it down as a prop.\",\n);\nexport const isAdmin = serverOnly(\n \"isAdmin()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const isEditor = serverOnly(\n \"isEditor()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const canEdit = serverOnly(\n \"canEdit()\",\n \"Call it in a Server Component; in a Client Component, read `canEdit` from useGRail().\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AASA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAEzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,WAAW,MAAc,MAAc;AAC9C,SAAO,IAAI,UAA4B;AACrC,UAAM,IAAI;AAAA,MACR,cAAc,IAAI,sCAAsC,IAAI;AAAA,IAG9D;AAAA,EACF;AACF;AAEO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AACF;AACO,MAAM,YAAY;AAAA,EACvB;AAAA,EACA;AACF;AACO,MAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AACF;AACO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;AACO,MAAM,WAAW;AAAA,EACtB;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { withGuardrail } from './server/wrapper.js';
2
- export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
2
+ export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
3
3
  export { GRail } from './react/GRail/index.js';
4
4
  export { GRepeater, GRepeaterProps, RepeaterItemProps } from './react/Repeater/index.js';
5
5
  export { configureGuardrail } from './core/config.js';
6
6
  export { getRepeaterItemBySlug, listRepeaterItems } from './server/repeaterData.js';
7
7
  export { getFieldValue } from './server/fieldData.js';
8
+ export { canEdit, isAdmin, isEditor } from './server/access.js';
8
9
  export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
9
10
  export { useGRail } from './react/Provider.js';
10
11
  export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  createAssetUpload,
8
8
  listAssets,
9
9
  setPasswordAction,
10
- skipSetPasswordAction
10
+ skipSetPasswordAction,
11
+ setFieldValue
11
12
  } from "./server/actions";
12
13
  import { GRail } from "./react/GRail";
13
14
  import { GRepeater } from "./react/Repeater";
@@ -17,6 +18,7 @@ import {
17
18
  getRepeaterItemBySlug
18
19
  } from "./server/repeaterData";
19
20
  import { getFieldValue } from "./server/fieldData";
21
+ import { isAdmin, isEditor, canEdit } from "./server/access";
20
22
  import {
21
23
  addRepeaterItem,
22
24
  removeRepeaterItem,
@@ -27,17 +29,21 @@ export {
27
29
  GRail,
28
30
  GRepeater,
29
31
  addRepeaterItem,
32
+ canEdit,
30
33
  configureGuardrail,
31
34
  createAssetUpload,
32
35
  getFieldValue,
33
36
  getRepeaterItemBySlug,
34
37
  inviteMember,
38
+ isAdmin,
39
+ isEditor,
35
40
  listAssets,
36
41
  listRepeaterItems,
37
42
  listTeam,
38
43
  removeMember,
39
44
  removeRepeaterItem,
40
45
  reorderRepeaterItems,
46
+ setFieldValue,
41
47
  setPasswordAction,
42
48
  skipSetPasswordAction,
43
49
  updateMemberRole,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Server-safe entry. Client components are reached through the files that\n// carry their own \"use client\" directive, so importing this from a Server\n// Component does not pull the editor into the client bundle.\nexport { withGuardrail } from \"./server/wrapper\";\n\n// Team management and the set-password step, exported so an app can build\n// its own UI instead of using the bundled drawer / sign-in bar.\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"./server/actions\";\nexport { GRail } from \"./react/GRail\";\nexport { GRepeater } from \"./react/Repeater\";\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport { configureGuardrail } from \"./core/config\";\nexport {\n listRepeaterItems,\n getRepeaterItemBySlug,\n} from \"./server/repeaterData\";\nexport { getFieldValue } from \"./server/fieldData\";\n// Mutations, exported so an app can build its own add/remove/reorder UI\n// instead of the bundled chrome — same rationale as the team actions above.\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\nexport { useGRail } from \"./react/Provider\";\n\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AAGA,SAAS,qBAAqB;AAI9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAE1B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAG9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Server-safe entry. Client components are reached through the files that\n// carry their own \"use client\" directive, so importing this from a Server\n// Component does not pull the editor into the client bundle.\nexport { withGuardrail } from \"./server/wrapper\";\n\n// Team management and the set-password step, exported so an app can build\n// its own UI instead of using the bundled drawer / sign-in bar.\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n setFieldValue,\n} from \"./server/actions\";\nexport { GRail } from \"./react/GRail\";\nexport { GRepeater } from \"./react/Repeater\";\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport { configureGuardrail } from \"./core/config\";\nexport {\n listRepeaterItems,\n getRepeaterItemBySlug,\n} from \"./server/repeaterData\";\nexport { getFieldValue } from \"./server/fieldData\";\n// Role / edit-mode checks for a custom admin UI and the Server Actions behind\n// it. Plain server functions, not actions — see access.ts for why.\nexport { isAdmin, isEditor, canEdit } from \"./server/access\";\n// Mutations, exported so an app can build its own add/remove/reorder UI\n// instead of the bundled chrome — same rationale as the team actions above.\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\nexport { useGRail } from \"./react/Provider\";\n\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AAGA,SAAS,qBAAqB;AAI9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAE1B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAG9B,SAAS,SAAS,UAAU,eAAe;AAG3C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
@@ -0,0 +1,76 @@
1
+ import { GRRole, GRViewer } from '../core/types.js';
2
+ import 'react';
3
+
4
+ /**
5
+ * Role checks, server-side only.
6
+ *
7
+ * Deliberately NOT in a "use server" file: every export of one of those is a
8
+ * callable Server Action endpoint, which is the wrong shape for an
9
+ * authorization helper — it should only ever run inside the server code that
10
+ * calls it.
11
+ *
12
+ * The client's copy of the role (useGRail().role) decides what to *show*; it
13
+ * never decides what is *allowed*. A hidden button is not authorization, so
14
+ * any Server Action behind a custom admin UI must call one of these itself.
15
+ */
16
+ /**
17
+ * Returns the signed-in viewer if their role is in `allowed`, otherwise null.
18
+ * The building block for the two helpers below; reach for this directly only
19
+ * when you need the viewer itself (their email, say), not just a yes/no.
20
+ */
21
+ declare function requireRole(allowed: readonly GRRole[]): Promise<GRViewer | null>;
22
+ /** True for a signed-in admin of this site — team management, and everything an editor can do. */
23
+ declare function isAdmin(): Promise<boolean>;
24
+ /**
25
+ * True for anyone who may edit this site's content: an editor, or an admin
26
+ * (admins can do everything editors can). The check for a custom drawer's
27
+ * Server Action that changes content. Use isAdmin() instead for anything that
28
+ * touches the team.
29
+ */
30
+ declare function isEditor(): Promise<boolean>;
31
+ /**
32
+ * True when this viewer may edit AND the site is currently in edit mode — the
33
+ * one check for "should I show editing UI / do edit-only work right now."
34
+ * Admin vs editor doesn't matter; use isAdmin() when it does.
35
+ *
36
+ * For a custom drawer's trigger rendered in a Server Component. In a Client
37
+ * Component, read `canEdit` from useGRail() instead — same answer, same rule.
38
+ * Don't use it as the only authorization on a Server Action: edit mode is a
39
+ * display preference (a cookie the visitor controls), so the action should
40
+ * call isEditor() — this answers "show it," that answers "allow it."
41
+ *
42
+ * Reads cookies, which makes the calling route dynamic — nothing new in
43
+ * practice, since withGuardrail() already does the same in the root layout.
44
+ */
45
+ declare function canEdit(): Promise<boolean>;
46
+ /**
47
+ * The one definition of "is the site in edit mode," shared by withGuardrail()
48
+ * (which hands it to the client as useGRail().isEditMode) and canEdit() so the
49
+ * two can't drift apart.
50
+ *
51
+ * "false" is an explicit, deliberate suppression (?edit=false while still
52
+ * signed in, to preview without chrome) and wins outright. Otherwise, a real
53
+ * session is enough on its own — an authenticated editor's chrome persists for
54
+ * as long as that session does, with no separate cookie timer to fall out of
55
+ * sync with it. "true" only matters for someone not yet authenticated: a
56
+ * short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS) to actually reach
57
+ * the sign-in form, not a standing invitation — the previous design kept this
58
+ * cookie alive indefinitely once set, which meant a stray copy of it kept
59
+ * showing the sign-in bar to whoever's browser it was sitting in, for as long
60
+ * as that browser stayed open.
61
+ *
62
+ * needsPassword is set by completeAuthRedirectAction the moment an
63
+ * invite/recovery link finishes — it forces chrome on regardless of the
64
+ * edit-mode cookie, since the whole point is to show the set-password prompt
65
+ * right away, not require also having ?edit=true or an unsuppressed cookie.
66
+ */
67
+ declare function resolveEditMode(viewer: GRViewer | null, cookieStore: {
68
+ get(name: string): {
69
+ value: string;
70
+ } | undefined;
71
+ }): {
72
+ isEditMode: boolean;
73
+ needsPassword: boolean;
74
+ };
75
+
76
+ export { canEdit, isAdmin, isEditor, requireRole, resolveEditMode };
@@ -0,0 +1,33 @@
1
+ import { cookies } from "next/headers";
2
+ import { getCachedViewer } from "./session";
3
+ import { GR_COOKIES } from "../core/defs";
4
+ async function requireRole(allowed) {
5
+ const viewer = await getCachedViewer();
6
+ if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;
7
+ return viewer;
8
+ }
9
+ async function isAdmin() {
10
+ return await requireRole(["admin"]) !== null;
11
+ }
12
+ async function isEditor() {
13
+ return await requireRole(["admin", "editor"]) !== null;
14
+ }
15
+ async function canEdit() {
16
+ const viewer = await requireRole(["admin", "editor"]);
17
+ if (!viewer) return false;
18
+ return resolveEditMode(viewer, await cookies()).isEditMode;
19
+ }
20
+ function resolveEditMode(viewer, cookieStore) {
21
+ const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;
22
+ const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === "true";
23
+ const isEditMode = needsPassword || (editCookie === "false" ? false : editCookie === "true" || viewer !== null);
24
+ return { isEditMode, needsPassword };
25
+ }
26
+ export {
27
+ canEdit,
28
+ isAdmin,
29
+ isEditor,
30
+ requireRole,
31
+ resolveEditMode
32
+ };
33
+ //# sourceMappingURL=access.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/access.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { getCachedViewer } from \"./session\";\nimport { GR_COOKIES } from \"../core/defs\";\nimport type { GRRole, GRViewer } from \"../core/types\";\n\n/**\n * Role checks, server-side only.\n *\n * Deliberately NOT in a \"use server\" file: every export of one of those is a\n * callable Server Action endpoint, which is the wrong shape for an\n * authorization helper — it should only ever run inside the server code that\n * calls it.\n *\n * The client's copy of the role (useGRail().role) decides what to *show*; it\n * never decides what is *allowed*. A hidden button is not authorization, so\n * any Server Action behind a custom admin UI must call one of these itself.\n */\n\n/**\n * Returns the signed-in viewer if their role is in `allowed`, otherwise null.\n * The building block for the two helpers below; reach for this directly only\n * when you need the viewer itself (their email, say), not just a yes/no.\n */\nexport async function requireRole(\n allowed: readonly GRRole[],\n): Promise<GRViewer | null> {\n const viewer = await getCachedViewer();\n if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;\n return viewer;\n}\n\n/** True for a signed-in admin of this site — team management, and everything an editor can do. */\nexport async function isAdmin(): Promise<boolean> {\n return (await requireRole([\"admin\"])) !== null;\n}\n\n/**\n * True for anyone who may edit this site's content: an editor, or an admin\n * (admins can do everything editors can). The check for a custom drawer's\n * Server Action that changes content. Use isAdmin() instead for anything that\n * touches the team.\n */\nexport async function isEditor(): Promise<boolean> {\n return (await requireRole([\"admin\", \"editor\"])) !== null;\n}\n\n/**\n * True when this viewer may edit AND the site is currently in edit mode — the\n * one check for \"should I show editing UI / do edit-only work right now.\"\n * Admin vs editor doesn't matter; use isAdmin() when it does.\n *\n * For a custom drawer's trigger rendered in a Server Component. In a Client\n * Component, read `canEdit` from useGRail() instead — same answer, same rule.\n * Don't use it as the only authorization on a Server Action: edit mode is a\n * display preference (a cookie the visitor controls), so the action should\n * call isEditor() — this answers \"show it,\" that answers \"allow it.\"\n *\n * Reads cookies, which makes the calling route dynamic — nothing new in\n * practice, since withGuardrail() already does the same in the root layout.\n */\nexport async function canEdit(): Promise<boolean> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return false;\n return resolveEditMode(viewer, await cookies()).isEditMode;\n}\n\n/**\n * The one definition of \"is the site in edit mode,\" shared by withGuardrail()\n * (which hands it to the client as useGRail().isEditMode) and canEdit() so the\n * two can't drift apart.\n *\n * \"false\" is an explicit, deliberate suppression (?edit=false while still\n * signed in, to preview without chrome) and wins outright. Otherwise, a real\n * session is enough on its own — an authenticated editor's chrome persists for\n * as long as that session does, with no separate cookie timer to fall out of\n * sync with it. \"true\" only matters for someone not yet authenticated: a\n * short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS) to actually reach\n * the sign-in form, not a standing invitation — the previous design kept this\n * cookie alive indefinitely once set, which meant a stray copy of it kept\n * showing the sign-in bar to whoever's browser it was sitting in, for as long\n * as that browser stayed open.\n *\n * needsPassword is set by completeAuthRedirectAction the moment an\n * invite/recovery link finishes — it forces chrome on regardless of the\n * edit-mode cookie, since the whole point is to show the set-password prompt\n * right away, not require also having ?edit=true or an unsuppressed cookie.\n */\nexport function resolveEditMode(\n viewer: GRViewer | null,\n cookieStore: { get(name: string): { value: string } | undefined },\n): { isEditMode: boolean; needsPassword: boolean } {\n const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;\n const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === \"true\";\n const isEditMode =\n needsPassword ||\n (editCookie === \"false\" ? false : editCookie === \"true\" || viewer !== null);\n return { isEditMode, needsPassword };\n}\n"],"mappings":"AAAA,SAAS,eAAe;AACxB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAqB3B,eAAsB,YACpB,SAC0B;AAC1B,QAAM,SAAS,MAAM,gBAAgB;AACrC,MAAI,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,QAAQ,SAAS,OAAO,IAAI,EAAG,QAAO;AACtE,SAAO;AACT;AAGA,eAAsB,UAA4B;AAChD,SAAQ,MAAM,YAAY,CAAC,OAAO,CAAC,MAAO;AAC5C;AAQA,eAAsB,WAA6B;AACjD,SAAQ,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC,MAAO;AACtD;AAgBA,eAAsB,UAA4B;AAChD,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,gBAAgB,QAAQ,MAAM,QAAQ,CAAC,EAAE;AAClD;AAuBO,SAAS,gBACd,QACA,aACiD;AACjD,QAAM,aAAa,YAAY,IAAI,WAAW,SAAS,GAAG;AAC1D,QAAM,gBAAgB,YAAY,IAAI,WAAW,cAAc,GAAG,UAAU;AAC5E,QAAM,aACJ,kBACC,eAAe,UAAU,QAAQ,eAAe,UAAU,WAAW;AACxE,SAAO,EAAE,YAAY,cAAc;AACrC;","names":[]}
@@ -1,4 +1,4 @@
1
- import { GRRole, GRAsset, GRMember, SetPasswordState, ActionState, GRViewer, OverrideRow } from '../core/types.js';
1
+ import { GRRole, GRAsset, GRMember, OverrideValue, SetPasswordState, ActionState, OverrideRow } from '../core/types.js';
2
2
  import { GRUploadTarget, AuthRedirectParams } from '../core/adapter-types.js';
3
3
  import 'react';
4
4
 
@@ -6,13 +6,26 @@ type Result = {
6
6
  success: boolean;
7
7
  error?: string;
8
8
  };
9
+ declare function saveOverride(row: OverrideRow): Promise<Result>;
9
10
  /**
10
- * Every mutation re-resolves the viewer server-side. The client's copy of the
11
- * role decides what to *show*; it never decides what is *allowed*. A hidden
12
- * button is not authorization — this is the same lesson as the session cookie.
11
+ * Writes one field's value by id, with no <GRail> involved — the setter that
12
+ * pairs with getFieldValue(). It exists for fields with nothing on the page to
13
+ * click: an SEO title or meta description, an og:image, a product's stock
14
+ * note. Build a drawer or form of your own, and have it (or a Server Action of
15
+ * yours) call this.
16
+ *
17
+ * A bare string is shorthand for a text field. Pass an OverrideValue for the
18
+ * other types ({ type: "image", src, alt }, a link, a button).
19
+ *
20
+ * Does its own role check — an editor or admin — so it is safe to call
21
+ * directly from a Client Component, the same as saveOverride. If your own
22
+ * action does other work first, check isEditor() there too.
23
+ *
24
+ * Writes the same row a <GRail> with this id would, so the two stay
25
+ * interchangeable: a field first written here can later be given a visible
26
+ * <GRail>, and vice versa.
13
27
  */
14
- declare function requireRole(allowed: readonly GRRole[]): Promise<GRViewer | null>;
15
- declare function saveOverride(row: OverrideRow): Promise<Result>;
28
+ declare function setFieldValue(id: string, value: string | OverrideValue): Promise<Result>;
16
29
  /**
17
30
  * Authorizes an upload and returns where the browser should send the bytes.
18
31
  * Only metadata comes through here — the file itself goes straight to storage.
@@ -66,4 +79,4 @@ declare function skipSetPasswordAction(): Promise<{
66
79
  success: boolean;
67
80
  }>;
68
81
 
69
- export { completeAuthRedirectAction, createAssetUpload, inviteMember, listAssets, listTeam, loginAction, logoutAction, removeMember, requireRole, saveOverride, setPasswordAction, skipSetPasswordAction, updateMemberRole };
82
+ export { completeAuthRedirectAction, createAssetUpload, inviteMember, listAssets, listTeam, loginAction, logoutAction, removeMember, saveOverride, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole };
@@ -10,22 +10,33 @@ import {
10
10
  resolveSiteId
11
11
  } from "../core/config";
12
12
  import { getCachedViewer } from "./session";
13
+ import { requireRole } from "./access";
13
14
  import { rethrowIfBailout } from "../core/bailout";
14
15
  import { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from "../core/defs";
15
16
  const DENIED = { success: false, error: "Unauthorized." };
16
- async function requireRole(allowed) {
17
- const viewer = await getCachedViewer();
18
- if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;
19
- return viewer;
20
- }
21
17
  async function saveOverride(row) {
22
18
  const viewer = await requireRole(["admin", "editor"]);
23
19
  if (!viewer) return DENIED;
20
+ return writeOverride(row.pathKey, row.value);
21
+ }
22
+ const OVERRIDE_TYPES = ["text", "link", "image", "button"];
23
+ async function setFieldValue(id, value) {
24
+ const viewer = await requireRole(["admin", "editor"]);
25
+ if (!viewer) return DENIED;
26
+ const pathKey = id.trim();
27
+ if (!pathKey) return { success: false, error: "A field id is required." };
28
+ const normalized = typeof value === "string" ? { type: "text", text: value } : value;
29
+ if (!OVERRIDE_TYPES.includes(normalized.type)) {
30
+ return { success: false, error: `Unknown field type "${normalized.type}".` };
31
+ }
32
+ return writeOverride(pathKey, normalized);
33
+ }
34
+ async function writeOverride(pathKey, value) {
24
35
  const rowToSend = {
25
- pathKey: row.pathKey,
36
+ pathKey,
26
37
  siteId: await resolveSiteId(),
27
- type: row.value.type,
28
- value: row.value
38
+ type: value.type,
39
+ value
29
40
  };
30
41
  try {
31
42
  const db = getDB();
@@ -249,8 +260,8 @@ export {
249
260
  loginAction,
250
261
  logoutAction,
251
262
  removeMember,
252
- requireRole,
253
263
  saveOverride,
264
+ setFieldValue,
254
265
  setPasswordAction,
255
266
  skipSetPasswordAction,
256
267
  updateMemberRole
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/actions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { cookies, headers } from \"next/headers\";\nimport {\n getAuth,\n getDB,\n getInviteHandler,\n getMembers,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../core/defs\";\nimport type {\n ActionState,\n GRAsset,\n GRMember,\n GRRole,\n GRViewer,\n OverrideRow,\n SetPasswordState,\n} from \"../core/types\";\nimport type {\n AuthRedirectParams,\n GRStorageRow,\n GRUploadTarget,\n} from \"../core/adapter-types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\n/**\n * Every mutation re-resolves the viewer server-side. The client's copy of the\n * role decides what to *show*; it never decides what is *allowed*. A hidden\n * button is not authorization — this is the same lesson as the session cookie.\n */\nexport async function requireRole(\n allowed: readonly GRRole[],\n): Promise<GRViewer | null> {\n const viewer = await getCachedViewer();\n if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;\n return viewer;\n}\n\nexport async function saveOverride(row: OverrideRow): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n const rowToSend: GRStorageRow = {\n pathKey: row.pathKey,\n siteId: await resolveSiteId(),\n type: row.value.type,\n value: row.value,\n };\n\n try {\n const db = getDB();\n const res = await db.setOverride(rowToSend);\n if (!res.success) return res;\n\n // TODO: too broad — purges every cached route on any single-field save.\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: save failed\", e);\n return { success: false, error: \"Database write failed.\" };\n }\n}\n\n/* --------------------------------------------------------------- media --- */\n\n// SVG is excluded on purpose: it can carry script, and next/image refuses it\n// by default anyway.\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/avif\"];\nconst MAX_UPLOAD_BYTES = 10 * 1024 * 1024;\n\n/**\n * Authorizes an upload and returns where the browser should send the bytes.\n * Only metadata comes through here — the file itself goes straight to storage.\n */\nexport async function createAssetUpload(file: {\n name: string;\n contentType: string;\n size: number;\n}): Promise<\n { success: true; target: GRUploadTarget } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n const storage = getStorage();\n if (!storage) {\n return { success: false, error: \"No storage adapter is configured.\" };\n }\n if (!IMAGE_TYPES.includes(file.contentType)) {\n return { success: false, error: \"Upload a PNG, JPEG, WebP, GIF, or AVIF image.\" };\n }\n if (!(file.size > 0) || file.size > MAX_UPLOAD_BYTES) {\n return { success: false, error: \"Images must be under 10 MB.\" };\n }\n\n try {\n const target = await storage.createUpload(await resolveSiteId(), file);\n return { success: true, target };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: createUpload failed\", e);\n // Failures here are almost always setup (missing SQL, bucket, or policy),\n // so show the cause while developing. Production keeps it generic rather\n // than exposing backend detail to editors.\n const detail =\n process.env.NODE_ENV !== \"production\" && e instanceof Error\n ? ` ${e.message}`\n : \"\";\n return { success: false, error: `Could not start the upload.${detail}` };\n }\n}\n\nexport async function listAssets(): Promise<GRAsset[]> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n const storage = getStorage();\n if (!viewer || !storage) return [];\n return storage.listAssets(await resolveSiteId());\n}\n\n/* ---------------------------------------------------------------- team --- */\n\nexport async function listTeam(): Promise<GRMember[]> {\n // Any member may see who else is on the site; only admins may change it.\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return [];\n\n const members = getMembers();\n return members.listMembers(await resolveSiteId());\n}\n\nexport async function inviteMember(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = email.toLowerCase().trim();\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(normalized)) {\n return { success: false, error: \"Enter a valid email address.\" };\n }\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const existing = await members.getMember(siteId, { email: normalized });\n if (existing?.status === \"active\") {\n return { success: false, error: \"That address is already on the team.\" };\n }\n\n // A pending invite is re-sent rather than rejected: provider email is\n // rate-limited and unreliable enough that resending has to be possible.\n if (!existing) {\n const res = await members.upsertMember(siteId, {\n email: normalized,\n role,\n userId: null,\n status: \"pending\",\n });\n if (!res.success) return res;\n }\n\n const auth = getAuth();\n if (auth.inviteUser) {\n // The membership is already written; a failed send must not undo it.\n const sent = await auth.inviteUser(normalized, {\n redirectTo: `${await requestOrigin()}/?edit=true`,\n });\n revalidatePath(\"/\", \"layout\");\n return sent.success\n ? { success: true }\n : {\n success: false,\n error: `Invite saved, but the email failed: ${sent.error}`,\n };\n }\n\n // No provider-side delivery, so hand off to the app.\n try {\n await getInviteHandler()?.({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy: viewer.email ?? viewer.userId,\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\nexport async function updateMemberRole(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const target = await members.getMember(siteId, {\n email: email.toLowerCase().trim(),\n });\n if (!target) return { success: false, error: \"No such member.\" };\n\n if (target.role === \"admin\" && role !== \"admin\") {\n const guard = await wouldStrandSite(siteId, target.email);\n if (guard) return guard;\n }\n\n const res = await members.upsertMember(siteId, {\n email: target.email,\n role,\n userId: target.userId,\n status: target.status,\n });\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nexport async function removeMember(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n const normalized = email.toLowerCase().trim();\n\n const guard = await wouldStrandSite(siteId, normalized);\n if (guard) return guard;\n\n const res = await members.removeMember(siteId, normalized);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nasync function requestOrigin(): Promise<string> {\n const h = await headers();\n const origin = h.get(\"origin\");\n if (origin) return origin;\n const host = h.get(\"x-forwarded-host\") ?? h.get(\"host\") ?? \"localhost\";\n const proto = h.get(\"x-forwarded-proto\") ?? (host.startsWith(\"localhost\") ? \"http\" : \"https\");\n return `${proto}://${host}`;\n}\n\n/**\n * Refuses changes that would leave a site with no admin, which would lock\n * everyone out of team management with no in-app way to recover.\n */\nasync function wouldStrandSite(\n siteId: string,\n email: string,\n): Promise<Result | null> {\n const members = getMembers();\n const all = await members.listMembers(siteId);\n const admins = all.filter((m) => m.role === \"admin\");\n\n if (admins.length <= 1 && admins.some((m) => m.email === email)) {\n return {\n success: false,\n error: \"A site must keep at least one admin.\",\n };\n }\n return null;\n}\n\n/* ---------------------------------------------------------------- auth --- */\n\nexport async function loginAction(\n _prev: ActionState,\n formData: FormData,\n): Promise<ActionState> {\n const email = String(formData.get(\"email\") ?? \"\");\n const password = String(formData.get(\"password\") ?? \"\");\n\n const auth = getAuth();\n if (!auth.authenticate) {\n return {\n success: false,\n error: \"This site's auth provider does not accept passwords.\",\n email,\n };\n }\n\n const result = await auth.authenticate(email, password);\n if (!result.success) {\n return { success: false, error: result.error, email };\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true, email };\n}\n\nexport async function logoutAction(): Promise<{ success: boolean }> {\n const auth = getAuth();\n await auth.logout();\n // Cleared here, not by the client afterward: a Server Action's own\n // revalidatePath() below triggers Next's router to refresh the current\n // route automatically, which can race a client-side navigation attempted\n // right after this resolves — confirmed live, where that automatic\n // refresh (for the pre-logout URL, ?edit=true) won the race and silently\n // undid a client-side `window.location.href` to the plain path. Deleting\n // the cookie here instead makes it part of the one server round-trip that\n // revalidatePath already accounts for, with nothing left to race.\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.EDIT_MODE);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\n/* ---------------------------------------------------------- redirects --- */\n\n/**\n * Completes a session that arrived via an invite, magic-link, or\n * password-recovery redirect. Called by AuthRedirectSync (react/), the\n * generic client component that notices this kind of link and hands off\n * whatever it found in the URL — the actual token exchange is entirely the\n * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only\n * orchestrates the result.\n *\n * A no-op {success:false}, not an error, when the adapter doesn't implement\n * this — most page loads carry no redirect data at all, and an adapter with\n * no such concept should not make every one of them fail.\n */\nexport async function completeAuthRedirectAction(\n params: AuthRedirectParams,\n): Promise<{ success: boolean; needsPassword?: boolean; error?: string }> {\n const auth = getAuth();\n if (!auth.completeAuthRedirect) return { success: false };\n\n const res = await auth.completeAuthRedirect(params);\n if (res.success) {\n if (res.needsPassword) {\n const cookieStore = await cookies();\n cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Sets the current session's password — the prompt completeAuthRedirectAction\n * triggers via needsPassword. Requires only a valid session, not a role: an\n * invited person's role is claimed as a side effect of getCachedViewer()\n * resolving them, which already happens independently of this.\n */\nexport async function setPasswordAction(\n _prev: SetPasswordState,\n formData: FormData,\n): Promise<SetPasswordState> {\n const viewer = await getCachedViewer();\n if (!viewer) return { success: false, error: \"Not signed in.\" };\n\n const auth = getAuth();\n if (!auth.setPassword) {\n return { success: false, error: \"This site's auth provider does not support setting a password.\" };\n }\n\n const password = String(formData.get(\"password\") ?? \"\");\n const res = await auth.setPassword(password);\n if (res.success) {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/** Dismisses the set-password prompt without setting one — see Admin.tsx. */\nexport async function skipSetPasswordAction(): Promise<{ success: boolean }> {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/server/actions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { cookies, headers } from \"next/headers\";\nimport {\n getAuth,\n getDB,\n getInviteHandler,\n getMembers,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { requireRole } from \"./access\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../core/defs\";\nimport type {\n ActionState,\n GRAsset,\n GRMember,\n GRRole,\n OverrideRow,\n OverrideType,\n OverrideValue,\n SetPasswordState,\n} from \"../core/types\";\nimport type {\n AuthRedirectParams,\n GRStorageRow,\n GRUploadTarget,\n} from \"../core/adapter-types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\n// Every mutation below re-resolves the viewer server-side through\n// requireRole() (see access.ts) — the client's copy of the role decides what\n// to *show*, never what is *allowed*.\n\nexport async function saveOverride(row: OverrideRow): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n return writeOverride(row.pathKey, row.value);\n}\n\nconst OVERRIDE_TYPES: readonly OverrideType[] = [\"text\", \"link\", \"image\", \"button\"];\n\n/**\n * Writes one field's value by id, with no <GRail> involved — the setter that\n * pairs with getFieldValue(). It exists for fields with nothing on the page to\n * click: an SEO title or meta description, an og:image, a product's stock\n * note. Build a drawer or form of your own, and have it (or a Server Action of\n * yours) call this.\n *\n * A bare string is shorthand for a text field. Pass an OverrideValue for the\n * other types ({ type: \"image\", src, alt }, a link, a button).\n *\n * Does its own role check — an editor or admin — so it is safe to call\n * directly from a Client Component, the same as saveOverride. If your own\n * action does other work first, check isEditor() there too.\n *\n * Writes the same row a <GRail> with this id would, so the two stay\n * interchangeable: a field first written here can later be given a visible\n * <GRail>, and vice versa.\n */\nexport async function setFieldValue(\n id: string,\n value: string | OverrideValue,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n const pathKey = id.trim();\n if (!pathKey) return { success: false, error: \"A field id is required.\" };\n\n const normalized: OverrideValue =\n typeof value === \"string\" ? { type: \"text\", text: value } : value;\n if (!OVERRIDE_TYPES.includes(normalized.type)) {\n return { success: false, error: `Unknown field type \"${normalized.type}\".` };\n }\n\n return writeOverride(pathKey, normalized);\n}\n\n/** The write shared by saveOverride and setFieldValue; the caller has already authorized. */\nasync function writeOverride(\n pathKey: string,\n value: OverrideValue,\n): Promise<Result> {\n const rowToSend: GRStorageRow = {\n pathKey,\n siteId: await resolveSiteId(),\n type: value.type,\n value,\n };\n\n try {\n const db = getDB();\n const res = await db.setOverride(rowToSend);\n if (!res.success) return res;\n\n // TODO: too broad — purges every cached route on any single-field save.\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: save failed\", e);\n return { success: false, error: \"Database write failed.\" };\n }\n}\n\n/* --------------------------------------------------------------- media --- */\n\n// SVG is excluded on purpose: it can carry script, and next/image refuses it\n// by default anyway.\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/avif\"];\nconst MAX_UPLOAD_BYTES = 10 * 1024 * 1024;\n\n/**\n * Authorizes an upload and returns where the browser should send the bytes.\n * Only metadata comes through here — the file itself goes straight to storage.\n */\nexport async function createAssetUpload(file: {\n name: string;\n contentType: string;\n size: number;\n}): Promise<\n { success: true; target: GRUploadTarget } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n const storage = getStorage();\n if (!storage) {\n return { success: false, error: \"No storage adapter is configured.\" };\n }\n if (!IMAGE_TYPES.includes(file.contentType)) {\n return { success: false, error: \"Upload a PNG, JPEG, WebP, GIF, or AVIF image.\" };\n }\n if (!(file.size > 0) || file.size > MAX_UPLOAD_BYTES) {\n return { success: false, error: \"Images must be under 10 MB.\" };\n }\n\n try {\n const target = await storage.createUpload(await resolveSiteId(), file);\n return { success: true, target };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: createUpload failed\", e);\n // Failures here are almost always setup (missing SQL, bucket, or policy),\n // so show the cause while developing. Production keeps it generic rather\n // than exposing backend detail to editors.\n const detail =\n process.env.NODE_ENV !== \"production\" && e instanceof Error\n ? ` ${e.message}`\n : \"\";\n return { success: false, error: `Could not start the upload.${detail}` };\n }\n}\n\nexport async function listAssets(): Promise<GRAsset[]> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n const storage = getStorage();\n if (!viewer || !storage) return [];\n return storage.listAssets(await resolveSiteId());\n}\n\n/* ---------------------------------------------------------------- team --- */\n\nexport async function listTeam(): Promise<GRMember[]> {\n // Any member may see who else is on the site; only admins may change it.\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return [];\n\n const members = getMembers();\n return members.listMembers(await resolveSiteId());\n}\n\nexport async function inviteMember(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = email.toLowerCase().trim();\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(normalized)) {\n return { success: false, error: \"Enter a valid email address.\" };\n }\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const existing = await members.getMember(siteId, { email: normalized });\n if (existing?.status === \"active\") {\n return { success: false, error: \"That address is already on the team.\" };\n }\n\n // A pending invite is re-sent rather than rejected: provider email is\n // rate-limited and unreliable enough that resending has to be possible.\n if (!existing) {\n const res = await members.upsertMember(siteId, {\n email: normalized,\n role,\n userId: null,\n status: \"pending\",\n });\n if (!res.success) return res;\n }\n\n const auth = getAuth();\n if (auth.inviteUser) {\n // The membership is already written; a failed send must not undo it.\n const sent = await auth.inviteUser(normalized, {\n redirectTo: `${await requestOrigin()}/?edit=true`,\n });\n revalidatePath(\"/\", \"layout\");\n return sent.success\n ? { success: true }\n : {\n success: false,\n error: `Invite saved, but the email failed: ${sent.error}`,\n };\n }\n\n // No provider-side delivery, so hand off to the app.\n try {\n await getInviteHandler()?.({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy: viewer.email ?? viewer.userId,\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\nexport async function updateMemberRole(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const target = await members.getMember(siteId, {\n email: email.toLowerCase().trim(),\n });\n if (!target) return { success: false, error: \"No such member.\" };\n\n if (target.role === \"admin\" && role !== \"admin\") {\n const guard = await wouldStrandSite(siteId, target.email);\n if (guard) return guard;\n }\n\n const res = await members.upsertMember(siteId, {\n email: target.email,\n role,\n userId: target.userId,\n status: target.status,\n });\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nexport async function removeMember(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n const normalized = email.toLowerCase().trim();\n\n const guard = await wouldStrandSite(siteId, normalized);\n if (guard) return guard;\n\n const res = await members.removeMember(siteId, normalized);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nasync function requestOrigin(): Promise<string> {\n const h = await headers();\n const origin = h.get(\"origin\");\n if (origin) return origin;\n const host = h.get(\"x-forwarded-host\") ?? h.get(\"host\") ?? \"localhost\";\n const proto = h.get(\"x-forwarded-proto\") ?? (host.startsWith(\"localhost\") ? \"http\" : \"https\");\n return `${proto}://${host}`;\n}\n\n/**\n * Refuses changes that would leave a site with no admin, which would lock\n * everyone out of team management with no in-app way to recover.\n */\nasync function wouldStrandSite(\n siteId: string,\n email: string,\n): Promise<Result | null> {\n const members = getMembers();\n const all = await members.listMembers(siteId);\n const admins = all.filter((m) => m.role === \"admin\");\n\n if (admins.length <= 1 && admins.some((m) => m.email === email)) {\n return {\n success: false,\n error: \"A site must keep at least one admin.\",\n };\n }\n return null;\n}\n\n/* ---------------------------------------------------------------- auth --- */\n\nexport async function loginAction(\n _prev: ActionState,\n formData: FormData,\n): Promise<ActionState> {\n const email = String(formData.get(\"email\") ?? \"\");\n const password = String(formData.get(\"password\") ?? \"\");\n\n const auth = getAuth();\n if (!auth.authenticate) {\n return {\n success: false,\n error: \"This site's auth provider does not accept passwords.\",\n email,\n };\n }\n\n const result = await auth.authenticate(email, password);\n if (!result.success) {\n return { success: false, error: result.error, email };\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true, email };\n}\n\nexport async function logoutAction(): Promise<{ success: boolean }> {\n const auth = getAuth();\n await auth.logout();\n // Cleared here, not by the client afterward: a Server Action's own\n // revalidatePath() below triggers Next's router to refresh the current\n // route automatically, which can race a client-side navigation attempted\n // right after this resolves — confirmed live, where that automatic\n // refresh (for the pre-logout URL, ?edit=true) won the race and silently\n // undid a client-side `window.location.href` to the plain path. Deleting\n // the cookie here instead makes it part of the one server round-trip that\n // revalidatePath already accounts for, with nothing left to race.\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.EDIT_MODE);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\n/* ---------------------------------------------------------- redirects --- */\n\n/**\n * Completes a session that arrived via an invite, magic-link, or\n * password-recovery redirect. Called by AuthRedirectSync (react/), the\n * generic client component that notices this kind of link and hands off\n * whatever it found in the URL — the actual token exchange is entirely the\n * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only\n * orchestrates the result.\n *\n * A no-op {success:false}, not an error, when the adapter doesn't implement\n * this — most page loads carry no redirect data at all, and an adapter with\n * no such concept should not make every one of them fail.\n */\nexport async function completeAuthRedirectAction(\n params: AuthRedirectParams,\n): Promise<{ success: boolean; needsPassword?: boolean; error?: string }> {\n const auth = getAuth();\n if (!auth.completeAuthRedirect) return { success: false };\n\n const res = await auth.completeAuthRedirect(params);\n if (res.success) {\n if (res.needsPassword) {\n const cookieStore = await cookies();\n cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Sets the current session's password — the prompt completeAuthRedirectAction\n * triggers via needsPassword. Requires only a valid session, not a role: an\n * invited person's role is claimed as a side effect of getCachedViewer()\n * resolving them, which already happens independently of this.\n */\nexport async function setPasswordAction(\n _prev: SetPasswordState,\n formData: FormData,\n): Promise<SetPasswordState> {\n const viewer = await getCachedViewer();\n if (!viewer) return { success: false, error: \"Not signed in.\" };\n\n const auth = getAuth();\n if (!auth.setPassword) {\n return { success: false, error: \"This site's auth provider does not support setting a password.\" };\n }\n\n const password = String(formData.get(\"password\") ?? \"\");\n const res = await auth.setPassword(password);\n if (res.success) {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/** Dismisses the set-password prompt without setting one — see Admin.tsx. */\nexport async function skipSetPasswordAction(): Promise<{ success: boolean }> {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n"],"mappings":"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","names":[]}
@@ -10,8 +10,10 @@ import 'react';
10
10
  * config.
11
11
  *
12
12
  * Read-only: this does not make a field editable on its own. Give the same
13
- * id a normal <GRail> somewhere for an editor to change it; this just reads
14
- * whatever that field currently holds.
13
+ * id a normal <GRail> somewhere for an editor to change it, or — for a field
14
+ * with nothing on the page to click, like a meta description — build your own
15
+ * editing UI around setFieldValue(). This just reads whatever the field
16
+ * currently holds.
15
17
  *
16
18
  * Shares <GRail>'s request-scoped batching loader, so calling this alongside
17
19
  * other fields — including several of these via Promise.all — still resolves
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/fieldData.ts"],"sourcesContent":["import { loadOverride } from \"./loader\";\nimport type { OverrideValue } from \"../core/types\";\n\n/**\n * Reads a field's current value with no rendering attached.\n *\n * <GRail> covers \"render this as an editable element.\" This covers \"give me\n * the data so I can build something GRail doesn't render itself\" — an embed\n * iframe's src, an og:image meta tag, structured data, a third-party widget's\n * config.\n *\n * Read-only: this does not make a field editable on its own. Give the same\n * id a normal <GRail> somewhere for an editor to change it; this just reads\n * whatever that field currently holds.\n *\n * Shares <GRail>'s request-scoped batching loader, so calling this alongside\n * other fields — including several of these via Promise.all — still resolves\n * in one query, not one per call.\n *\n * Returns null when nothing has been saved yet. There is no `children`\n * fallback to fall back to here, since nothing is rendered — supply your own\n * default in the calling code.\n */\nexport async function getFieldValue(id: string): Promise<OverrideValue | null> {\n const row = await loadOverride(id);\n return row?.value ?? null;\n}\n"],"mappings":"AAAA,SAAS,oBAAoB;AAuB7B,eAAsB,cAAc,IAA2C;AAC7E,QAAM,MAAM,MAAM,aAAa,EAAE;AACjC,SAAO,KAAK,SAAS;AACvB;","names":[]}
1
+ {"version":3,"sources":["../../src/server/fieldData.ts"],"sourcesContent":["import { loadOverride } from \"./loader\";\nimport type { OverrideValue } from \"../core/types\";\n\n/**\n * Reads a field's current value with no rendering attached.\n *\n * <GRail> covers \"render this as an editable element.\" This covers \"give me\n * the data so I can build something GRail doesn't render itself\" — an embed\n * iframe's src, an og:image meta tag, structured data, a third-party widget's\n * config.\n *\n * Read-only: this does not make a field editable on its own. Give the same\n * id a normal <GRail> somewhere for an editor to change it, or — for a field\n * with nothing on the page to click, like a meta description — build your own\n * editing UI around setFieldValue(). This just reads whatever the field\n * currently holds.\n *\n * Shares <GRail>'s request-scoped batching loader, so calling this alongside\n * other fields — including several of these via Promise.all — still resolves\n * in one query, not one per call.\n *\n * Returns null when nothing has been saved yet. There is no `children`\n * fallback to fall back to here, since nothing is rendered — supply your own\n * default in the calling code.\n */\nexport async function getFieldValue(id: string): Promise<OverrideValue | null> {\n const row = await loadOverride(id);\n return row?.value ?? null;\n}\n"],"mappings":"AAAA,SAAS,oBAAoB;AAyB7B,eAAsB,cAAc,IAA2C;AAC7E,QAAM,MAAM,MAAM,aAAa,EAAE;AACjC,SAAO,KAAK,SAAS;AACvB;","names":[]}
@@ -1,7 +1,7 @@
1
1
  "use server";
2
2
  import { revalidatePath } from "next/cache";
3
3
  import { getDB, getRepeaters, resolveSiteId } from "../core/config";
4
- import { requireRole } from "./actions";
4
+ import { requireRole } from "./access";
5
5
  import { rethrowIfBailout } from "../core/bailout";
6
6
  const DENIED = { success: false, error: "Unauthorized." };
7
7
  async function addRepeaterItem(repeaterId, options) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/repeaterActions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { getDB, getRepeaters, resolveSiteId } from \"../core/config\";\nimport { requireRole } from \"./actions\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport type { GRRepeaterItem } from \"../core/types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\nexport async function addRepeaterItem(\n repeaterId: string,\n options?: { slug?: string },\n): Promise<\n { success: true; item: GRRepeaterItem } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n if (options?.slug !== undefined) {\n const slug = options.slug.trim();\n if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {\n return {\n success: false,\n error: \"Use lowercase letters, numbers, and hyphens only.\",\n };\n }\n options = { slug };\n }\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.addItem(repeaterId, await resolveSiteId(), options);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: addRepeaterItem failed\", e);\n return { success: false, error: \"Could not add the item.\" };\n }\n}\n\nexport async function removeRepeaterItem(\n repeaterId: string,\n itemId: string,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n try {\n const siteId = await resolveSiteId();\n const repeaters = getRepeaters();\n const res = await repeaters.removeItem(repeaterId, siteId, itemId);\n if (!res.success) return res;\n\n // Best-effort: an adapter that can't do this cheaply just leaves the old\n // field values behind, unreachable but harmless.\n const db = getDB();\n await db.deleteOverrides?.(`${repeaterId}.${itemId}.`, siteId);\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: removeRepeaterItem failed\", e);\n return { success: false, error: \"Could not remove the item.\" };\n }\n}\n\nexport async function reorderRepeaterItems(\n repeaterId: string,\n orderedItemIds: string[],\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.reorderItems(\n repeaterId,\n await resolveSiteId(),\n orderedItemIds,\n );\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: reorderRepeaterItems failed\", e);\n return { success: false, error: \"Could not reorder the items.\" };\n }\n}\n"],"mappings":";AAEA,SAAS,sBAAsB;AAC/B,SAAS,OAAO,cAAc,qBAAqB;AACnD,SAAS,mBAAmB;AAC5B,SAAS,wBAAwB;AAKjC,MAAM,SAAiB,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAEhE,eAAsB,gBACpB,YACA,SAGA;AACA,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAE7D,MAAI,SAAS,SAAS,QAAW;AAC/B,UAAM,OAAO,QAAQ,KAAK,KAAK;AAC/B,QAAI,CAAC,2BAA2B,KAAK,IAAI,GAAG;AAC1C,aAAO;AAAA,QACL,SAAS;AAAA,QACT,OAAO;AAAA,MACT;AAAA,IACF;AACA,cAAU,EAAE,KAAK;AAAA,EACnB;AAEA,MAAI;AACF,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU,QAAQ,YAAY,MAAM,cAAc,GAAG,OAAO;AAC9E,QAAI,IAAI,QAAS,gBAAe,KAAK,QAAQ;AAC7C,WAAO;AAAA,EACT,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,qCAAqC,CAAC;AACpD,WAAO,EAAE,SAAS,OAAO,OAAO,0BAA0B;AAAA,EAC5D;AACF;AAEA,eAAsB,mBACpB,YACA,QACiB;AACjB,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AAEpB,MAAI;AACF,UAAM,SAAS,MAAM,cAAc;AACnC,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU,WAAW,YAAY,QAAQ,MAAM;AACjE,QAAI,CAAC,IAAI,QAAS,QAAO;AAIzB,UAAM,KAAK,MAAM;AACjB,UAAM,GAAG,kBAAkB,GAAG,UAAU,IAAI,MAAM,KAAK,MAAM;AAE7D,mBAAe,KAAK,QAAQ;AAC5B,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,wCAAwC,CAAC;AACvD,WAAO,EAAE,SAAS,OAAO,OAAO,6BAA6B;AAAA,EAC/D;AACF;AAEA,eAAsB,qBACpB,YACA,gBACiB;AACjB,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AAEpB,MAAI;AACF,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU;AAAA,MAC1B;AAAA,MACA,MAAM,cAAc;AAAA,MACpB;AAAA,IACF;AACA,QAAI,IAAI,QAAS,gBAAe,KAAK,QAAQ;AAC7C,WAAO;AAAA,EACT,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,0CAA0C,CAAC;AACzD,WAAO,EAAE,SAAS,OAAO,OAAO,+BAA+B;AAAA,EACjE;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/server/repeaterActions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { getDB, getRepeaters, resolveSiteId } from \"../core/config\";\nimport { requireRole } from \"./access\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport type { GRRepeaterItem } from \"../core/types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\nexport async function addRepeaterItem(\n repeaterId: string,\n options?: { slug?: string },\n): Promise<\n { success: true; item: GRRepeaterItem } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n if (options?.slug !== undefined) {\n const slug = options.slug.trim();\n if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {\n return {\n success: false,\n error: \"Use lowercase letters, numbers, and hyphens only.\",\n };\n }\n options = { slug };\n }\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.addItem(repeaterId, await resolveSiteId(), options);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: addRepeaterItem failed\", e);\n return { success: false, error: \"Could not add the item.\" };\n }\n}\n\nexport async function removeRepeaterItem(\n repeaterId: string,\n itemId: string,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n try {\n const siteId = await resolveSiteId();\n const repeaters = getRepeaters();\n const res = await repeaters.removeItem(repeaterId, siteId, itemId);\n if (!res.success) return res;\n\n // Best-effort: an adapter that can't do this cheaply just leaves the old\n // field values behind, unreachable but harmless.\n const db = getDB();\n await db.deleteOverrides?.(`${repeaterId}.${itemId}.`, siteId);\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: removeRepeaterItem failed\", e);\n return { success: false, error: \"Could not remove the item.\" };\n }\n}\n\nexport async function reorderRepeaterItems(\n repeaterId: string,\n orderedItemIds: string[],\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.reorderItems(\n repeaterId,\n await resolveSiteId(),\n orderedItemIds,\n );\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: reorderRepeaterItems failed\", e);\n return { success: false, error: \"Could not reorder the items.\" };\n }\n}\n"],"mappings":";AAEA,SAAS,sBAAsB;AAC/B,SAAS,OAAO,cAAc,qBAAqB;AACnD,SAAS,mBAAmB;AAC5B,SAAS,wBAAwB;AAKjC,MAAM,SAAiB,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAEhE,eAAsB,gBACpB,YACA,SAGA;AACA,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAE7D,MAAI,SAAS,SAAS,QAAW;AAC/B,UAAM,OAAO,QAAQ,KAAK,KAAK;AAC/B,QAAI,CAAC,2BAA2B,KAAK,IAAI,GAAG;AAC1C,aAAO;AAAA,QACL,SAAS;AAAA,QACT,OAAO;AAAA,MACT;AAAA,IACF;AACA,cAAU,EAAE,KAAK;AAAA,EACnB;AAEA,MAAI;AACF,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU,QAAQ,YAAY,MAAM,cAAc,GAAG,OAAO;AAC9E,QAAI,IAAI,QAAS,gBAAe,KAAK,QAAQ;AAC7C,WAAO;AAAA,EACT,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,qCAAqC,CAAC;AACpD,WAAO,EAAE,SAAS,OAAO,OAAO,0BAA0B;AAAA,EAC5D;AACF;AAEA,eAAsB,mBACpB,YACA,QACiB;AACjB,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AAEpB,MAAI;AACF,UAAM,SAAS,MAAM,cAAc;AACnC,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU,WAAW,YAAY,QAAQ,MAAM;AACjE,QAAI,CAAC,IAAI,QAAS,QAAO;AAIzB,UAAM,KAAK,MAAM;AACjB,UAAM,GAAG,kBAAkB,GAAG,UAAU,IAAI,MAAM,KAAK,MAAM;AAE7D,mBAAe,KAAK,QAAQ;AAC5B,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,wCAAwC,CAAC;AACvD,WAAO,EAAE,SAAS,OAAO,OAAO,6BAA6B;AAAA,EAC/D;AACF;AAEA,eAAsB,qBACpB,YACA,gBACiB;AACjB,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AAEpB,MAAI;AACF,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU;AAAA,MAC1B;AAAA,MACA,MAAM,cAAc;AAAA,MACpB;AAAA,IACF;AACA,QAAI,IAAI,QAAS,gBAAe,KAAK,QAAQ;AAC7C,WAAO;AAAA,EACT,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,0CAA0C,CAAC;AACzD,WAAO,EAAE,SAAS,OAAO,OAAO,+BAA+B;AAAA,EACjE;AACF;","names":[]}
@@ -8,8 +8,8 @@ import {
8
8
  resolveSiteId
9
9
  } from "../core/config";
10
10
  import { getCachedViewer } from "./session";
11
+ import { resolveEditMode } from "./access";
11
12
  import { loadPreloadedContent } from "./loader";
12
- import { GR_COOKIES } from "../core/defs";
13
13
  import { GRailProvider } from "../react/Provider";
14
14
  import { GRailAdmin } from "../react/Admin";
15
15
  function withGuardrail(LayoutComponent, config) {
@@ -22,10 +22,9 @@ function withGuardrail(LayoutComponent, config) {
22
22
  loadPreloadedContent()
23
23
  ]);
24
24
  const auth = getAuth();
25
- const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;
26
25
  const isAuth = viewer !== null;
27
- const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === "true";
28
- const isEditMode = needsPassword || (editCookie === "false" ? false : editCookie === "true" || isAuth);
26
+ const { isEditMode, needsPassword } = resolveEditMode(viewer, cookieStore);
27
+ const role = viewer?.role ?? null;
29
28
  const state = {
30
29
  storage: getStorage() !== void 0,
31
30
  auth: {
@@ -36,7 +35,8 @@ function withGuardrail(LayoutComponent, config) {
36
35
  isEditMode,
37
36
  needsPassword,
38
37
  siteId: await resolveSiteId(),
39
- role: viewer?.role ?? null,
38
+ role,
39
+ canEdit: isEditMode && role !== null,
40
40
  ...viewer?.email ? { email: viewer.email } : {}
41
41
  };
42
42
  return /* @__PURE__ */ jsxs(GRailProvider, { state, content, children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/wrapper.tsx"],"sourcesContent":["import React, { Suspense } from \"react\";\nimport { cookies } from \"next/headers\";\nimport {\n configureGuardrail,\n getAuth,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { loadPreloadedContent } from \"./loader\";\nimport type { GRConfig } from \"../core/adapter-types\";\nimport { GR_COOKIES } from \"../core/defs\";\nimport { GRailProvider } from \"../react/Provider\";\nimport { GRailAdmin } from \"../react/Admin\";\nimport type { GRState } from \"../core/types\";\n\n/**\n * Wraps a root layout so every route carries Guardrail's edit state and admin\n * chrome. Stays a Server Component: the provider below is the client boundary.\n */\nexport function withGuardrail<P extends object>(\n LayoutComponent: React.ComponentType<P>,\n config: GRConfig,\n) {\n // Applied at module evaluation so the config is present as soon as anything\n // imports the layout, and again per render because Next.js does not\n // guarantee one shared module instance across its server contexts.\n configureGuardrail(config);\n\n return async function GuardrailLayout(props: P) {\n configureGuardrail(config);\n\n const cookieStore = await cookies();\n\n // Verified against the auth provider, not inferred from a cookie. Loaded\n // alongside the content preload: independent round trips, and the preload\n // is shared with the page's own fields, so it costs them nothing extra.\n const [viewer, content] = await Promise.all([\n getCachedViewer(),\n loadPreloadedContent(),\n ]);\n\n const auth = getAuth();\n\n // \"false\" is an explicit, deliberate suppression (?edit=false while still\n // signed in, to preview without chrome) and wins outright. Otherwise, a\n // real session is enough on its own — an authenticated editor's chrome\n // persists for as long as that session does, with no separate cookie\n // timer to fall out of sync with it. \"true\" only matters for someone not\n // yet authenticated: a short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS)\n // to actually reach the sign-in form, not a standing invitation — the\n // previous design kept this cookie alive indefinitely once set, which\n // meant a stray copy of it kept showing the sign-in bar to whoever's\n // browser it was sitting in, for as long as that browser stayed open.\n const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;\n const isAuth = viewer !== null;\n // Set by completeAuthRedirectAction the moment an invite/recovery link\n // finishes — forces chrome on regardless of the edit-mode cookie, since\n // the whole point is to show the set-password prompt right away, not\n // require also having ?edit=true or an unsuppressed cookie.\n const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === \"true\";\n const isEditMode =\n needsPassword || (editCookie === \"false\" ? false : editCookie === \"true\" || isAuth);\n\n const state: GRState = {\n storage: getStorage() !== undefined,\n auth: {\n password: typeof auth.authenticate === \"function\",\n ...(auth.signInUrl ? { signInUrl: auth.signInUrl } : {}),\n },\n isAuth,\n isEditMode,\n needsPassword,\n siteId: await resolveSiteId(),\n role: viewer?.role ?? null,\n ...(viewer?.email ? { email: viewer.email } : {}),\n };\n\n return (\n <GRailProvider state={state} content={content}>\n <LayoutComponent {...props} />\n <Suspense fallback={null}>\n <GRailAdmin toolbarExtra={config.toolbarExtra} />\n </Suspense>\n </GRailProvider>\n );\n };\n}\n"],"mappings":"AA+EM,SACE,KADF;AA/EN,OAAO,SAAS,gBAAgB;AAChC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AAErC,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB;AAOpB,SAAS,cACd,iBACA,QACA;AAIA,qBAAmB,MAAM;AAEzB,SAAO,eAAe,gBAAgB,OAAU;AAC9C,uBAAmB,MAAM;AAEzB,UAAM,cAAc,MAAM,QAAQ;AAKlC,UAAM,CAAC,QAAQ,OAAO,IAAI,MAAM,QAAQ,IAAI;AAAA,MAC1C,gBAAgB;AAAA,MAChB,qBAAqB;AAAA,IACvB,CAAC;AAED,UAAM,OAAO,QAAQ;AAYrB,UAAM,aAAa,YAAY,IAAI,WAAW,SAAS,GAAG;AAC1D,UAAM,SAAS,WAAW;AAK1B,UAAM,gBAAgB,YAAY,IAAI,WAAW,cAAc,GAAG,UAAU;AAC5E,UAAM,aACJ,kBAAkB,eAAe,UAAU,QAAQ,eAAe,UAAU;AAE9E,UAAM,QAAiB;AAAA,MACrB,SAAS,WAAW,MAAM;AAAA,MAC1B,MAAM;AAAA,QACJ,UAAU,OAAO,KAAK,iBAAiB;AAAA,QACvC,GAAI,KAAK,YAAY,EAAE,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,MACxD;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ,MAAM,cAAc;AAAA,MAC5B,MAAM,QAAQ,QAAQ;AAAA,MACtB,GAAI,QAAQ,QAAQ,EAAE,OAAO,OAAO,MAAM,IAAI,CAAC;AAAA,IACjD;AAEA,WACE,qBAAC,iBAAc,OAAc,SAC3B;AAAA,0BAAC,mBAAiB,GAAG,OAAO;AAAA,MAC5B,oBAAC,YAAS,UAAU,MAClB,8BAAC,cAAW,cAAc,OAAO,cAAc,GACjD;AAAA,OACF;AAAA,EAEJ;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/server/wrapper.tsx"],"sourcesContent":["import React, { Suspense } from \"react\";\nimport { cookies } from \"next/headers\";\nimport {\n configureGuardrail,\n getAuth,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { resolveEditMode } from \"./access\";\nimport { loadPreloadedContent } from \"./loader\";\nimport type { GRConfig } from \"../core/adapter-types\";\nimport { GRailProvider } from \"../react/Provider\";\nimport { GRailAdmin } from \"../react/Admin\";\nimport type { GRState } from \"../core/types\";\n\n/**\n * Wraps a root layout so every route carries Guardrail's edit state and admin\n * chrome. Stays a Server Component: the provider below is the client boundary.\n */\nexport function withGuardrail<P extends object>(\n LayoutComponent: React.ComponentType<P>,\n config: GRConfig,\n) {\n // Applied at module evaluation so the config is present as soon as anything\n // imports the layout, and again per render because Next.js does not\n // guarantee one shared module instance across its server contexts.\n configureGuardrail(config);\n\n return async function GuardrailLayout(props: P) {\n configureGuardrail(config);\n\n const cookieStore = await cookies();\n\n // Verified against the auth provider, not inferred from a cookie. Loaded\n // alongside the content preload: independent round trips, and the preload\n // is shared with the page's own fields, so it costs them nothing extra.\n const [viewer, content] = await Promise.all([\n getCachedViewer(),\n loadPreloadedContent(),\n ]);\n\n const auth = getAuth();\n\n // The rule for what counts as \"in edit mode\" lives in resolveEditMode —\n // shared with canEdit() so the layout and the server helper can't disagree.\n const isAuth = viewer !== null;\n const { isEditMode, needsPassword } = resolveEditMode(viewer, cookieStore);\n const role = viewer?.role ?? null;\n\n const state: GRState = {\n storage: getStorage() !== undefined,\n auth: {\n password: typeof auth.authenticate === \"function\",\n ...(auth.signInUrl ? { signInUrl: auth.signInUrl } : {}),\n },\n isAuth,\n isEditMode,\n needsPassword,\n siteId: await resolveSiteId(),\n role,\n canEdit: isEditMode && role !== null,\n ...(viewer?.email ? { email: viewer.email } : {}),\n };\n\n return (\n <GRailProvider state={state} content={content}>\n <LayoutComponent {...props} />\n <Suspense fallback={null}>\n <GRailAdmin toolbarExtra={config.toolbarExtra} />\n </Suspense>\n </GRailProvider>\n );\n };\n}\n"],"mappings":"AAkEM,SACE,KADF;AAlEN,OAAO,SAAS,gBAAgB;AAChC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AAErC,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB;AAOpB,SAAS,cACd,iBACA,QACA;AAIA,qBAAmB,MAAM;AAEzB,SAAO,eAAe,gBAAgB,OAAU;AAC9C,uBAAmB,MAAM;AAEzB,UAAM,cAAc,MAAM,QAAQ;AAKlC,UAAM,CAAC,QAAQ,OAAO,IAAI,MAAM,QAAQ,IAAI;AAAA,MAC1C,gBAAgB;AAAA,MAChB,qBAAqB;AAAA,IACvB,CAAC;AAED,UAAM,OAAO,QAAQ;AAIrB,UAAM,SAAS,WAAW;AAC1B,UAAM,EAAE,YAAY,cAAc,IAAI,gBAAgB,QAAQ,WAAW;AACzE,UAAM,OAAO,QAAQ,QAAQ;AAE7B,UAAM,QAAiB;AAAA,MACrB,SAAS,WAAW,MAAM;AAAA,MAC1B,MAAM;AAAA,QACJ,UAAU,OAAO,KAAK,iBAAiB;AAAA,QACvC,GAAI,KAAK,YAAY,EAAE,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,MACxD;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ,MAAM,cAAc;AAAA,MAC5B;AAAA,MACA,SAAS,cAAc,SAAS;AAAA,MAChC,GAAI,QAAQ,QAAQ,EAAE,OAAO,OAAO,MAAM,IAAI,CAAC;AAAA,IACjD;AAEA,WACE,qBAAC,iBAAc,OAAc,SAC3B;AAAA,0BAAC,mBAAiB,GAAG,OAAO;AAAA,MAC5B,oBAAC,YAAS,UAAU,MAClB,8BAAC,cAAW,cAAc,OAAO,cAAc,GACjD;AAAA,OACF;AAAA,EAEJ;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guard-rail/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Lightweight inline content editing for developer-owned Next.js sites.",
5
5
  "license": "ISC",
6
6
  "type": "module",